Стандарты написания тестов
Настоящий документ определяет жёсткие стандарты написания тестов для проекта YaoXiang. Все участники обязаны соблюдать следующие правила; нарушители будут обязаны внести изменения в ходе Code Review.
Содержание
- Общие положения
- Стандарты модульных тестов
- Стандарты интеграционных тестов
- Стандарты бенчмарков
- Стандарты doctest
- Стандарты property-based тестов
- Требования к покрытию
- Приложения
Общие положения
Область применения
Настоящий стандарт применяется ко всему коду тестов на Rust в проекте YaoXiang:
| Тип теста | Расположение | Фреймворк |
|---|---|---|
| Модульные | src/<module>/tests/ | #[test] + #[cfg(test)] |
| Интеграц. | tests/ | #[test] |
| Бенчмарки | benches/ | Criterion.rs |
| Doctest | API-документация | cargo test --doc |
| Property | В любом месте тестов | proptest / quickcheck |
Ключевые принципы
Принцип 0: Единственным авторитетным источником для тестов является спецификация, а не код. Это самый важный принцип данного документа. Тесты проверяют соответствие кода спецификации, а не то, «работает ли код так, как он сейчас реализован». Когда тест обнаруживает расхождение между поведением кода и спецификацией, исправлять следует код, а не тест.
Файлы спецификаций находятся в:
docs/src/design/language-spec.md—— ядро языковой спецификацииdocs/src/design/rfc/accepted/—— принятые RFC-дизайны
В начале каждого файла тестов должна быть указана соответствующая секция спецификации (см. правило 2.1). Любой разработчик должен иметь возможность взять спецификацию и, сверяясь с тестами, проверить корректность реализации. И наоборот — если фрагмент кода не имеет соответствующего описания в спецификации, его не должно существовать, и он не должен тестироваться.
// 🟢 Хорошо——тест напрямую ссылается на спецификацию, проверяя соответствие кода
//! Тесты литералов — на основе спецификации языка §2.6
//!
//! §2.6.1: Целые Decimal, Octal(0o), Hex(0x), Binary(0b)
//! §2.6.2: Числа с плавающей запятой (с десятичной точкой и экспонентой)
//! §2.6.3: Строки (escape-последовательности \\nrt'"\\, \\x, \\u{})
//! RFC-012: F-String интерполяция
#[test]
fn test_decimal_literal_parsing() {
// Спецификация §2.6.1: Decimal ::= [0-9][0-9_]*
let result = parse_literal("42").unwrap();
assert_eq!(result, Literal::Int(42));
}
// 🔴 Мусор——тест подстраивается под текущее поведение кода, а не проверяет спецификацию
#[test]
fn test_literal_1() {
// Неизвестно, к какой секции спецификации относится
// Если parse_literal вернёт неверное значение, тест «пройдёт зелёным»
// потому что он лишь проверяет, что функция не паникует
let result = parse_literal("42");
assert!(result.is_ok());
}Ситуация: Вы написали тест и обнаружили, что поведение кода не соответствует спецификации. У вас два варианта:
| Неправильный подход | Правильный подход |
|---|---|
| Изменить тест, чтобы он «проходил» | Исправить код, чтобы он соответствовал спецификации |
Добавить #[ignore] в тест | Немедленно исправить код |
| Добавить в тест условные ветки под код | Удалить ветки, чтобы тест обнажил проблему |
Запомните: Красный = ошибка в коде, а не в тесте. (Если только сам тест не содержит баг — это другое дело.)
Принцип 1: Тесты являются документацией. Любой разработчик должен понимать поведение тестируемого кода, читая тесты, без дополнительных комментариев или внешней документации.
// 🟢 Хорошо——имя теста говорит, что тестируется и что ожидается
#[test]
fn test_tokenize_empty_input_returns_eof() {
let tokens = tokenize("").unwrap();
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].kind, TokenKind::Eof));
}
// 🔴 Мусор——непонятно, что это тестирует
#[test]
fn test_tokenize_1() {
let tokens = tokenize("").unwrap();
assert!(tokens.len() > 0);
}Принцип 2: Нулевая терпимость к случайным сбоям. Тесты должны быть воспроизводимы в любой среде. Тесты, зависящие от случайных чисел, системного времени или порядка планирования потоков, должны использовать фиксированные сиды или mock-объекты.
Принцип 3: Один тест проверяет одно. Если для описания теста нужно связать слова «и», разделите на несколько тестов.
// 🟢 Хорошо——каждый тест проверяет один сценарий
#[test]
fn test_parse_int_positive() { /* ... */ }
#[test]
fn test_parse_int_zero() { /* ... */ }
// 🔴 Мусор——один тест напичкан множеством несвязанного
#[test]
fn test_parser() {
// Тестируем tokenize, parse, typecheck, codegen...
}Принцип 4: Тестируем поведение, а не реализацию. Рефакторинг внутренней реализации не должен приводить к падению тестов. Если изменив одну строку реализации, у вас падает 10 тестов — вы написали тесты неправильно.
Но здесь важно различать: определение «поведения» исходит из спецификации, а не из текущего поведения кода. Если код изменил поведение (то есть появилось новое поведение, не соответствующее спецификации), тесты должны падать. Если вы не можете этого обеспечить — значит, ваши тесты «подстраиваются под код» — они пропускают баги.
Спецификация (language-spec.md / RFC) ──определяет──► Ожидаемое поведение ──управляет──► Тесты
│
Текущий код ──реализует──► Фактическое поведение ──сравнивается──► Результат тестов
Если фактическое ≠ ожидаемому:
Тесты должны падать (красный) ──► Исправляем код ──► Тесты проходят (зелёный)
Если фактическое = ожидаемому (но реализация плохая):
Тесты проходят ──► Рефакторинг реализации ──► Тесты всё ещё проходят ← Вот смысл принципа 4Принцип 5: Не писать тестовый код с обходными/совместимыми/условными паттернами. Тестовая среда — это среда, которую вы полностью контролируете. Если вам нужно #[cfg(not(ci))] для пропуска теста — значит, дизайн теста имеет фундаментальную проблему.
Определения терминов
| Термин | Определение |
|---|---|
| Модульный | Тестирование отдельной функции или модуля без внешних зависимостей |
| Интеграц. | Тестирование взаимодействия нескольких модулей через публичный API или CLI |
| Бенчмарк | Измерение производительности, обнаружение регрессий |
| Doctest | Исполняемые примеры кода в документации |
| Property | Тестирование инвариантов на случайных входных данных |
Связь со стандартами коммитов
Все коммиты, связанные с тестами, должны использовать тип :white_check_mark: test: согласно стандартам коммитов.
:white_check_mark: test(parser): добавить тесты инфиксных выражений Pratt-парсера
:white_check_mark: test(codegen): дополнить тесты генерации IR для switchСтандарты модульных тестов
Организация файлов
Правило 1.1: Каталог tests/ для модульных тестов должен располагаться на одном уровне с mod.rs тестируемого модуля. tests/ не агрегируется вверх и не объединяется на нескольких уровнях.
src/frontend/core/parser/
├── mod.rs # #[cfg(test)] mod tests; ——объявляет tests/ на одном уровне
├── ast.rs
├── pratt/
│ ├── mod.rs # #[cfg(test)] mod tests; ——тесты pratt внутри себя
│ └── tests/
│ ├── mod.rs
│ ├── led.rs
│ ├── nud.rs
│ └── precedence.rs
└── tests/ # тесты уровня модуля parser (без содержимого подмодуля pratt)
├── mod.rs
├── ast.rs
├── expressions.rs
├── error_recovery.rs
└── parser_state.rsКлючевой критерий: tests/ находится в том каталоге, где mod.rs обязан объявить #[cfg(test)] mod tests;.
Правило 1.1 дополнение: запрет на агрегацию вверх. Тесты подмодулей должны находиться в собственном tests/ этого подмодуля, а не агрегироваться в родительский tests/.
| Тип модуля | Расположение тестов | Пример |
|---|---|---|
Модуль-каталог (есть mod.rs) | tests/ внутри каталога | emitter/tests/, codes/tests/ |
Однофайловый модуль (только .rs) | tests/ родителя | session.rs → diagnostic/tests/session.rs |
# ✅ Правильно: тесты каждого каталожного модуля независимы
src/util/diagnostic/
├── codes/
│ ├── mod.rs # #[cfg(test)] mod tests;
│ └── tests/ # ✅ тесты codes отдельно
│ ├── mod.rs
│ └── codes.rs
├── emitter/
│ ├── mod.rs # #[cfg(test)] mod tests;
│ └── tests/ # ✅ тесты emitter отдельно
│ ├── mod.rs
│ ├── text.rs
│ └── ansi.rs
└── tests/ # ✅ уровень diagnostic (однофайловые модули)
├── mod.rs
├── session.rs
├── suggest.rs
└── collect.rs
# ❌ Неправильно: тесты emitter и codes агрегированы в diagnostic/tests/
src/util/diagnostic/
└── tests/
├── mod.rs # ❌ вынужден объявлять mod emitter; mod codes;
├── emitter/ # ❌ должно быть в emitter/tests/
└── codes/ # ❌ должно быть в codes/tests/Правила размещения тестов для однофайловых и каталожных модулей
Основное различие: Организационная форма модуля определяет место тестов.
| Тип модуля | Критерий | Расположение тестов | Пример |
|---|---|---|---|
| Каталожный | Есть отдельный каталог и mod.rs | tests/ внутри | inference/tests/ |
| Однофайловый | Только .rs файл, нет каталога | tests/ родителя | overload.rs → typecheck/tests/overload.rs |
Подробное объяснение:
src/frontend/core/typecheck/
├── mod.rs # mod.rs модуля typecheck
├── checker.rs # однофайловый модуль
├── environment.rs # однофайловый модуль
├── overload.rs # однофайловый модуль
├── type_eval.rs # однофайловый модуль
├── dead_code.rs # однофайловый модуль
├── spawn_placement.rs # однофайловый модуль
├── signature.rs # однофайловый модуль
├── types.rs # однофайловый модуль
│
├── tests/ # ✅ каталог тестов typecheck
│ ├── mod.rs # объявляет тесты однофайловых модулей
│ ├── checker.rs # тесты checker.rs
│ ├── environment.rs # тесты environment.rs
│ ├── overload.rs # тесты overload.rs (однофайловый модуль здесь)
│ ├── type_eval.rs # тесты type_eval.rs
│ ├── dead_code.rs # тесты dead_code.rs
│ ├── spawn_placement.rs # тесты spawn_placement.rs
│ ├── signature.rs # тесты signature.rs
│ └── types.rs # тесты types.rs
│
├── inference/ # каталожный модуль (есть mod.rs)
│ ├── mod.rs # #[cfg(test)] mod tests; ——объявляет tests/ на одном уровне
│ ├── expressions.rs
│ ├── statements.rs
│ ├── patterns.rs
│ ├── bounds.rs
│ ├── subtyping.rs
│ ├── generics.rs
│ ├── compatibility.rs
│ ├── scope.rs
│ ├── assignment.rs
│ └── tests/ # ✅ каталог тестов inference
│ ├── mod.rs
│ ├── expressions.rs # тесты expressions.rs
│ ├── statements.rs # тесты statements.rs
│ └── ...
│
└── traits/ # удалён (логика объединена в types/trait_data.rs)Почему тесты однофайловых модулей в родительском tests/?
Потому что однофайловый модуль (например, overload.rs) не имеет собственного mod.rs, поэтому не может объявить #[cfg(test)] mod tests;. Согласно системе модулей Rust, тестовые файлы должны быть объявлены в каком-либо mod.rs для компиляции. Поэтому тесты однофайловых модулей объявляются в mod.rs родительского модуля и располагаются в tests/ родителя.
Алгоритм принятия решения:
Встретили модуль — куда поместить тесты?
│
├── Это каталожный модуль (есть mod.rs)?
│ └── Да → Создать tests/ внутри, объявить в его mod.rs
│
├── Это однофайловый модуль (только .rs)?
│ └── Да → Тесты в tests/ родителя, объявить в mod.rs родителя
│
└── Не уверены?
└── Проверить наличие отдельного каталога и mod.rsТипичные ошибки:
# ❌ Ошибка 1: Создание отдельного tests/ для однофайлового модуля
src/frontend/core/typecheck/
├── overload.rs
└── overload/ # ❌ Ненужный каталог для однофайлового модуля
└── tests/
└── overload.rs
# ❌ Ошибка 2: Объявление #[cfg(test)] mod tests; в однофайловом модуле
# overload.rs
#[cfg(test)] # ❌ Однофайловый модуль не может так объявлять
mod tests; # потому что нет каталога overload/tests/
# ✅ Правильно: тесты в родительском tests/
src/frontend/core/typecheck/
├── overload.rs # исходный файл
└── tests/
└── overload.rs # тестовый файл, объявлен в typecheck/mod.rs⚠️ Антипаттерн — так не делать:
# ❌ Неправильно: тесты подмодулей собраны у родителя
src/frontend/core/types/
├── mod.rs # должен был объявить только base и computation
├── base/
│ ├── mod.rs
│ └── var.rs
└── tests/ # ❌ тесты родителя содержат тесты подмодулей
├── mod.rs # ❌ вынужден объявлять mod base; mod computation;
├── base/ # ❌ должно быть в base/tests/
│ └── var.rs
└── computation/ # ❌ должно быть в computation/tests/
└── ...# ✅ Правильно: тесты каждого модуля независимы
src/frontend/core/types/
├── mod.rs # объявляет только pub mod base; pub mod computation;
├── base/
│ ├── mod.rs # #[cfg(test)] mod tests; ——объявляет tests/ на одном уровне
│ ├── var.rs
│ └── tests/
│ ├── mod.rs
│ └── var.rs
└── computation/
├── mod.rs # #[cfg(test)] mod tests; ——объявляет tests/ на одном уровне
├── operations.rs
└── tests/
├── mod.rs
└── operations.rsПочему нельзя агрегировать вверх? Потому что система модулей Rust требует, чтобы #[cfg(test)] mod tests; решался в месте объявления. Если types/mod.rs объявляет mod tests;, то содержимое types/tests/ является приватным содержимым модуля types — оно не должно заходить на территорию base или computation. Тесты каждого модуля должны быть деталью реализации этого модуля, а не родительского. Это правило также применимо при рефакторинге модулей: когда вы разделяете types на base и computation, тесты тоже должны разделиться вслед за новыми модулями, а не остаться на месте. Каталог тестов не зеркалирует структуру исходников, а следует за границами модулей.
Правило 1.2: tests/mod.rs отвечает только за объявления модулей и re-export, без тестовых функций.
//! Тесты ядра парсера — зеркало src/frontend/core/parser/
//!
//! Тесты для ast.rs, parser_state.rs и парсинга выражений/интеграции.
mod ast;
mod error_recovery;
mod expressions;
mod integration;
mod parser_state;Правило 1.3: Каждый тестовый файл соответствует одному исходному файлу. Недопустимо смешивать тесты нескольких исходных модулей в одном файле.
Правило 1.4: Объявление тестов должно быть в форме файла mod tests; (с точкой с запятой), указывающей на каталог tests/ на одном уровне. Запрещена inline-форма mod tests { ... } с кодом тестов непосредственно в исходном файле.
// ✅ Правильно——объявление в форме файла, тесты в отдельных файлах
// src/frontend/core/parser/mod.rs
#[cfg(test)]
mod tests;
// 🔴 Запрещено——inline-форма, тестовый код паразитирует в исходном файле
// src/frontend/core/parser/mod.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_something() {
// Тестовый код не должен появляться в исходном файле
}
}Почему запрещён inline?
- Единая ответственность исходного файла: исходный файл содержит только реализацию, тестовый файл — только тесты. При смешивании приходится прокручивать к концу файла для тестов и пропускать их для реализации.
- Чёткие границы модулей: каталог
tests/— это физическая граница, сразу видно, какие модули протестированы, а какие нет. - Безопасность рефакторинга: при разделении модуля каталог
tests/переносится вместе с ним; inline-тесты требуют ручного извлечения из исходного файла. - Прозрачность ревью: в diff PR изменения исходного кода и тестов — это разные файлы, они не смешиваются.
Стандарты объявления модулей
Правило 2.1: Все тестовые файлы должны иметь модульный doc-комментарий //! в начале, указывающий источник покрытия в спецификации (номер секции спецификации языка + номер RFC). Если тест не ссылается ни на одну секцию спецификации — значит, этот код не имеет обоснования в спецификации, его не должно существовать.
//! Тесты литералов — на основе спецификации языка §2.6
//!
//! §2.6.1: Целые Decimal, Octal(0o), Hex(0x), Binary(0b)
//! §2.6.2: Числа с плавающей запятой (с десятичной точкой и экспонентой)
//! §2.6.3: Строки (escape-последовательности \\nrt'"\\, \\x, \\u{})
//! RFC-012: F-String интерполяцияПочему обязательна ссылка на спецификацию? Потому что ожидаемые значения тестов берутся из спецификации, а не из «текущего вывода кода». Если有一天 код изменит вывод, а тест随之更新 — значит, тест ничего не защищает. Только тесты, привязанные к спецификации, могут отличить «намеренное breaking change» от «случайной регрессии».
Правило 2.2: Импорты use в тестовых модулях должны быть точными до конкретного типа/функции, запрещены glob-импорты use super::*.
// 🟢 Хорошо——точный импорт
use crate::frontend::core::lexer::{tokenize, TokenKind};
use crate::frontend::core::parser::{ParserState, ParseError};
// 🔴 Мусор——непонятно, что именно тестируется
use super::*;Стандарты именования
Правило 3.1: Формат имени тестовой функции: test_<что>_<сценарий>, всё строчными буквами, слова разделены подчёркиванием.
#[test]
fn test_tokenize_empty_string() { /* ... */ }
#[test]
fn test_parse_int_overflow() { /* ... */ }
#[test]
fn test_typecheck_fn_return_mismatch() { /* ... */ }Правило 3.2: Имя тестовой функции должно быть самодокументирующимся. Прочитав имя, должно быть понятно, что тестируется и что ожидается. Запрещено именование числовыми индексами.
// 🟢 Хорошо
fn test_skip_semicolon_success() { /* ... */ }
fn test_skip_semicolon_failure_when_identifier() { /* ... */ }
// 🔴 Мусор——совершенно непонятно, что тестируется
fn test_skip_1() { /* ... */ }
fn test_skip_2() { /* ... */ }Правило 3.3: Вспомогательным функциям не нужен префикс test_, они должны описываться глаголом или существительным.
fn parse_expr(source: &str) -> Expr { /* ... */ }
fn tokenize_single(source: &str) -> Token { /* ... */ }
fn setup_parser_with_tokens(tokens: &[Token]) -> ParserState { /* ... */ }Стандарты структуры тестов (Arrange-Act-Assert)
Правило 4.1: Каждая тестовая функция должна следовать трёхсекционной структуре: Подготовка (Arrange) → Действие (Act) → Проверка (Assert), секции разделяются пустой строкой.
#[test]
fn test_parse_binary_addition() {
// Arrange
let source = "1 + 2";
// Act
let expr = parse_expr(source);
// Assert
assert!(matches!(expr, Expr::Binary { op: BinOp::Add, .. }));
}Правило 4.2: Простые тесты (один вызов + одно утверждение) могут не иметь секционных комментариев, но не более 5 строк логического кода. Тесты свыше 5 строк должны явно размечать три секции.
Стандарты вспомогательных функций
Правило 5.1: Повторяющийся код инициализации (setup) 3 и более раз должен быть извлечён во вспомогательную функцию.
// 🟢 Хорошо——извлечён общий setup
fn with_state<F>(source: &str, mut f: F)
where
F: FnMut(&mut ParserState<'_>),
{
let tokens = tokenize(source).unwrap();
let mut state = ParserState::new(&tokens);
f(&mut state);
}
#[test]
fn test_current_returns_first_token() {
with_state("42", |state| {
let tok = state.current();
assert_eq!(&tok.unwrap().kind, &TokenKind::IntLiteral(42));
});
}Правило 5.2: unwrap() / expect() во вспомогательных функциях должен при панике выводить достаточный контекст. В теле тестовой функции (#[test] fn ...) можно напрямую использовать unwrap()——при неудаче Rust автоматически выводит номер строки; но при неудаче внутри вспомогательной функции номер строки указывает на определение вспомогательной функции, а не на место вызова.
// 🟢 Хорошо——при неудаче выводится содержимое исходного кода
fn run_ok(source: &str) {
run(source).unwrap_or_else(|e| panic!("Execution failed:\nSource:\n{}\nError:\n{:?}", source, e));
}
// 🔴 Мусор——при неудаче непонятно, какой файл вызвал проблему
fn run_ok(source: &str) {
run(source).unwrap();
}Правило 5.3: Вспомогательные функции следует размещать в верхней части тестового файла, сразу после импортов use. Если используются несколькими тестовыми модулями — поместить в tests/mod.rs и экспортировать через pub(crate).
Стиль утверждений
Правило 6.1: Для сопоставления вариантов перечисления предпочитайте assert!(matches!(...)), не используйте if let + panic!.
// 🟢 Хорошо
assert!(matches!(tokens[0].kind, TokenKind::IntLiteral(42)));
// 🔴 Мусор
if let TokenKind::IntLiteral(v) = tokens[0].kind {
assert_eq!(v, 42);
} else {
panic!("Expected IntLiteral");
}Правило 6.2: Для точного сравнения значений используйте assert_eq!, для булевых утверждений — assert!. Запрещено использовать assert!(a == b) вместо assert_eq!(a, b).
Правило 6.3: Все утверждения должны содержать пользовательское сообщение об ошибке, если само утверждение не описывает причину неудачи.
// 🟢 Хорошо——при неудаче можно быстро локализовать
assert!(
state.infix_info().is_some(),
"infix_info should handle '{op}'"
);
// 🟢 Хорошо——assert_eq! при неудаче автоматически выводит разницу значений
assert_eq!(error_count, 0);
// 🔴 Мусор——при неудаче只知道 "assertion failed"
assert!(state.infix_info().is_some());Правило 6.4: Порядок в утверждениях должен быть assert_eq!(actual, expected) — фактическое значение первым, ожидаемое вторым.
Чек-лист антипаттернов
Ниже приведены запрещённые写法及其替代方案:
| Антипаттерн | Проблема | Замена |
|---|---|---|
#[cfg(test)] mod tests { ... } inline | Раздувание исходника, размытые границы, сложный рефакторинг | Тесты в отдельном каталоге tests/, объявление mod tests; (см. правило 1.4) |
| Тесты подстраиваются под ошибки кода | Сокрыение отклонений от спецификации, легализация багов | Сверить со спецификацией, исправить код, тесты не менять |
| Вывод ожидаемых значений из вывода кода | Тесты становятся «магнитофоном текущей реализации» | Ожидаемые значения из спецификации |
Постоянная метка #[ignore] | Скрывает гниющие тесты | Исправить или удалить |
println! для отладки | Загрязняет вывод тестов | Использовать assert! для явных проверок |
thread::sleep | Случайные сбои + медленно | Использовать синхронизацию или mock |
| Работа с реальной ФС в тестах | Медленно и невоспроизводимо | Использовать tempfile |
| Зависимость от порядка выполнения | Случайные сбои | Независимый setup для каждого теста |
| Тестовая функция > 30 строк логики | Нечитаемо | Разделить тесты или использовать вспомогательные функции |
unwrap() без контекста во вспомогательных | Сложная локализация | Использовать expect("почему") или кастомный panic (см. правило 5.2) |
| copy-paste одинакового setup 3+ раза | Высокая стоимость изменений | Извлечь вспомогательную функцию |
Стандарты интеграционных тестов
Организация тестов
Правило 7.1: Интеграционные тесты располагаются в каталоге tests/ корня проекта. Входной файл tests/integration.rs использует атрибут #[path] для подключения подмодулей.
// tests/integration.rs
#[path = "integration/backends.rs"]
mod backends;
#[path = "integration/codegen.rs"]
mod codegen;
#[path = "integration/execution.rs"]
mod execution;Правило 7.2: Каждый файл tests/integration/*.rs соответствует одной теме тестирования (бэкенды компилятора, кодогенерация, исполнитель и т.д.), не смешивать.
Правило 7.3: Интеграционные тесты должны проходить через публичный API проекта. Запрещено в интеграционных тестах напрямую ссылаться на внутренние модули crate::. Использовать публичный путь yaoxiang::.
// 🟢 Хорошо——через публичный API
use yaoxiang::run;
// 🔴 Мусор——绕过 публичный API
use yaoxiang::middle::codegen::bytecode::BytecodeFile;Управление тестовыми данными
Правило 8.1: В интеграционных тестах предпочитать inline-строки с исходным кодом. Внешние fixture-файлы использовать только когда исходный код превышает 30 строк (располагать в tests/fixtures/).
#[test]
fn test_fibonacci() {
run_ok(
r#"
main = {
mut a = 0
mut b = 1
while a < 100 {
mut next = a + b
a = b
b = next
}
}
"#,
);
}Правило 8.2: Fixture-файлы должны иметь расширение .yx, имя файла описывает тестируемый сценарий.
Принципы E2E-покрытия
Правило 9.1: Интеграционные тесты каждой языковой фичи должны покрывать три пути:
| Путь | Описание |
|---|---|
| Happy path | Корректный ввод produces ожидаемый вывод |
| Error path | Некорректный ввод produces чёткое сообщение об ошибке (не panic) |
| Boundary | Граничные значения (пустой ввод, максимумы, предел глубины вложенности) |
Правило 9.2: Интеграционные тесты не должны зависеть от сети, системных переменных окружения или внешних сервисов.
Стандарты бенчмарков
Стандарты использования Criterion.rs
Правило 10.1: Бенчмарки统一放在 benches/ 目录,入口文件为 benches/lib.rs。按测试主题分文件。
benches/
├── lib.rs # 入口,定义 criterion_group/criterion_main
├── lang_compare/
│ └── fibonacci.rs # 跨语言对比基准
├── parser.rs # 解析器基准
└── codegen.rs # 代码生成基准Правило 10.2: 每个基准函数必须包含模块文档注释 //! 说明测试目的和测量指标。
//! YaoXiang 解释器性能基准测试
//!
//! 测量指标:单次迭代耗时(wall time)
//! 基准线:Rust 原生实现防止编译器优化
Правило 11.1: 所有基准测试的被测输出必须通过 criterion::black_box 阻止编译器优化消除。
use criterion::{black_box, Criterion};
fn bench_parse(c: &mut Criterion) {
c.bench_function("parse_fib", |b| {
b.iter(|| {
let result = parse(black_box(FIB_SOURCE));
black_box(result)
})
});
}Правило 11.2: 基准测试的输入数据必须是 const 或 lazy_static,不得在 iter 闭包内动态生成——否则测量的是数据生成 + 被测逻辑的总时间。
基准分组与命名
Правило 12.1: 基准测试命名格式为 <被测模块>_<场景>,全小写下划线分隔。与单元测试命名规则一致。
Правило 12.2: 必须使用 criterion_group! 对相关基准进行逻辑分组。禁止所有基准挤在一个分组中。
criterion_group!(parser, bench_parse_expr, bench_parse_stmt);
criterion_group!(codegen, bench_codegen_module, bench_codegen_switch);
criterion_main!(parser, codegen);Стандарты文档测试
使用场景
Правило 13.1: 所有 pub 函数、类型、方法必须在文档注释中包含至少一个可运行的代码示例。该示例通过 cargo test --doc 执行。
/// 将源码字符串分词为 Token 序列。
///
/// ```
/// use yaoxiang::frontend::core::lexer::tokenize;
///
/// let tokens = tokenize("42").unwrap();
/// assert_eq!(tokens.len(), 2); // IntLiteral + Eof
/// ```
pub fn tokenize(source: &str) -> Result<Vec<Token>, LexError> {
// ...
}Правило 13.2: 文档测试的代码示例必须编译通过且断言成功。不得包含 ignore 标记的示例,除非该示例展示的是编译期错误。
/// ```ignore
/// // 展示编译期错误——可以 ignore
/// let x: int = "string";
/// ```覆盖要求
Правило 14.1: 文档测试覆盖 API 的 happy path 即可。边界情况和错误路径由单元测试覆盖。
Правило 14.2: 文档测试中的示例代码必须简洁——不超过 10 行。如果示例需要更长的上下文,说明 API 设计有问题。
属性测试规范
使用场景
Правило 15.1: 以下场景必须使用属性测试(proptest 或 quickcheck)而不是手写多个边界值用例:
| 场景 | 示例 |
|---|---|
| 解析器 round-trip | parse(pretty_print(ast)) == ast |
| 序列化/反序列化 | deserialize(serialize(data)) == data |
| 数学运算恒等式 | a + b == b + a |
| 编译器优化不改变语义 | eval(code) == eval(optimize(code)) |
Правило 15.2: 属性测试使用 proptest 作为主要的属性测试框架(已在 Cargo.toml 的 dev-dependencies 中声明)。
use proptest::prelude::*;
proptest! {
#[test]
fn test_roundtrip_serialize_deserialize(value: i64) {
let serialized = serialize(&value);
let deserialized: i64 = deserialize(&serialized).unwrap();
prop_assert_eq!(deserialized, value);
}
}属性定义原则
Правило 16.1: 每个属性测试必须有明确的属性声明——注释中写明验证的不变量。
// 属性:任意整数字面量在 tokenize → tokens_to_string 后产生相同值
proptest! {
#[test]
fn test_int_literal_roundtrip(n in any::<i64>()) {
let source = n.to_string();
let tokens = tokenize(&source).unwrap();
// ...
}
}Правило 16.2: 如果属性测试发现失败,必须使用 proptest 的回归机制——将失败的输入添加到 proptest-regressions/ 目录,不要手写一个普通测试代替。
覆盖率要求
新增代码覆盖率目标
Правило 17.1: 新增代码的测试覆盖率要求:
| 代码类型 | 行覆盖率 | 分支覆盖率 |
|---|---|---|
| 核心编译器模块(frontend/middle/backends) | ≥ 85% | ≥ 80% |
| 工具/辅助模块(util) | ≥ 75% | ≥ 70% |
| 运行时模块(vm/runtime) | ≥ 80% | ≥ 75% |
| 标准库(std) | ≥ 75% | ≥ 70% |
| 错误处理和诊断 | ≥ 90% | ≥ 85% |
Правило 17.2: 错误处理路径(所有 Err 分支)必须 100% 覆盖。用户能看到的错误信息必须被测试验证过。
PR 审查检查清单
Правило 18.1: 提交 PR 前,作者必须自查以下项目:
- [ ]
cargo test全部通过 - [ ]
cargo test --doc全部通过 - [ ]
cargo bench无性能回归(如涉及热路径变更) - [ ] 新增代码符合覆盖率目标
- [ ] 测试命名符合命名规范
- [ ] 每个测试文件声明了对应的规范章节(规则 2.1)
- [ ] 测试期望值来自规范定义,而非"当前代码的输出"
- [ ] 无
#[ignore]标记的测试(除非有明确 issue 号注释) - [ ] 无不必要的
unwrap()(应使用expect或自定义 panic 消息) - [ ] 提交信息使用
:white_check_mark: test:类型 - [ ] 没有因为"代码行为与规范不符"而修改测试期望值——改的是代码,不是测试
- [ ] 没有 inline 测试(
#[cfg(test)] mod tests { ... }必须改为mod tests;+ 独立文件,见规则 1.4)
Правило 18.2: Reviewer 必须拒绝包含以下问题的 PR:
- 只有 happy path 测试,缺少错误路径
- 测试中有
thread::sleep或依赖执行顺序 - 复制粘贴的测试代码超过 3 次而未提取辅助函数
- 测试名不符合命名规范
- 存在永久
#[ignore]的测试 - 测试迁就代码的错误行为(代码与规范不符时修改测试而非修改代码)
- 测试没有声明对应的规范章节(参见规则 2.1)
- 测试期望值来自代码输出而非规范定义(反推出来的测试等于没测)
- 存在 inline 测试(
#[cfg(test)] mod tests { ... }而非mod tests;+ 独立文件,参见规则 1.4) - 测试只验证"不 panic"而不断言具体行为
- 删除了暴露代码 bug 的失败测试(而不是修复代码后再看到它变绿)
附录
A. 测试命令速查
# 运行所有测试
cargo test
# 只运行单元测试
cargo test --lib
# 只运行集成测试
cargo test --test integration
# 只运行文档测试
cargo test --doc
# 运行特定测试(按名称过滤)
cargo test test_parse_expr
# 运行基准测试
cargo bench
# 显示测试输出(默认隐藏 stdout)
cargo test -- --nocapture
# 单线程运行(排查并发问题)
cargo test -- --test-threads=1
# 生成覆盖率报告(需要 cargo-llvm-cov)
cargo llvm-cov --htmlB. 提交信息模板
测试相关提交必须遵循以下模板:
:white_check_mark: test(<scope>): <简短描述>
<可选:覆盖的场景列表>示例:
:white_check_mark: test(parser): 添加 Pratt 解析器中缀运算符测试
覆盖场景:
- 算术运算符优先级(+, -, *, /, %)
- 比较运算符链接(1 < x < 10)
- 逻辑运算符短路
- 赋值运算符右结合C. 新增测试文件清单
创建新的测试模块时,确保包含以下文件:
# 在 src/<module>/ 目录下新增测试
src/<module>/tests/
├── mod.rs # 模块声明 + 公共辅助函数
└── <subject>.rs # 测试文件,对应被测源文件命名
# 在 tests/ 目录下新增集成测试
tests/
├── integration.rs # 更新:添加 #[path] 声明
└── integration/
└── <topic>.rs # 新测试文件D. 参考资料
- YaoXiang 语言规范 —— 测试的权威来源
- 已接受的 RFC —— 设计决策的权威来源
- Rust 测试文档
- Criterion.rs 用户指南
- proptest 文档
- 项目提交规范
- 项目贡献指南
💡 记住:测试不验证你的代码是否"能跑"——它验证你的代码是否符合规范。规范在变,测试跟着规范变。代码写错了,改代码,不要改测试。代码服务于规范,测试守护规范。测试迁就代码的那一刻,你就失去了所有保护。
