Skip to content

Стандарты написания тестов

Настоящий документ определяет жёсткие стандарты написания тестов для проекта YaoXiang. Все участники обязаны соблюдать следующие правила; нарушители будут обязаны внести изменения в ходе Code Review.


Содержание


Общие положения

Область применения

Настоящий стандарт применяется ко всему коду тестов на Rust в проекте YaoXiang:

Тип тестаРасположениеФреймворк
Модульныеsrc/<module>/tests/#[test] + #[cfg(test)]
Интеграц.tests/#[test]
Бенчмаркиbenches/Criterion.rs
DoctestAPI-документацияcargo test --doc
PropertyВ любом месте тестовproptest / quickcheck

Ключевые принципы

Принцип 0: Единственным авторитетным источником для тестов является спецификация, а не код. Это самый важный принцип данного документа. Тесты проверяют соответствие кода спецификации, а не то, «работает ли код так, как он сейчас реализован». Когда тест обнаруживает расхождение между поведением кода и спецификацией, исправлять следует код, а не тест.

Файлы спецификаций находятся в:

  • docs/src/design/language-spec.md —— ядро языковой спецификации
  • docs/src/design/rfc/accepted/ —— принятые RFC-дизайны

В начале каждого файла тестов должна быть указана соответствующая секция спецификации (см. правило 2.1). Любой разработчик должен иметь возможность взять спецификацию и, сверяясь с тестами, проверить корректность реализации. И наоборот — если фрагмент кода не имеет соответствующего описания в спецификации, его не должно существовать, и он не должен тестироваться.

rust
// 🟢 Хорошо——тест напрямую ссылается на спецификацию, проверяя соответствие кода
//! Тесты литералов — на основе спецификации языка §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: Тесты являются документацией. Любой разработчик должен понимать поведение тестируемого кода, читая тесты, без дополнительных комментариев или внешней документации.

rust
// 🟢 Хорошо——имя теста говорит, что тестируется и что ожидается
#[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: Один тест проверяет одно. Если для описания теста нужно связать слова «и», разделите на несколько тестов.

rust
// 🟢 Хорошо——каждый тест проверяет один сценарий
#[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.rsdiagnostic/tests/session.rs
text
# ✅ Правильно: тесты каждого каталожного модуля независимы
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.rstests/ внутриinference/tests/
ОднофайловыйТолько .rs файл, нет каталогаtests/ родителяoverload.rstypecheck/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, без тестовых функций.

rust
//! Тесты ядра парсера — зеркало 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 { ... } с кодом тестов непосредственно в исходном файле.

rust
// ✅ Правильно——объявление в форме файла, тесты в отдельных файлах
// 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?

  1. Единая ответственность исходного файла: исходный файл содержит только реализацию, тестовый файл — только тесты. При смешивании приходится прокручивать к концу файла для тестов и пропускать их для реализации.
  2. Чёткие границы модулей: каталог tests/ — это физическая граница, сразу видно, какие модули протестированы, а какие нет.
  3. Безопасность рефакторинга: при разделении модуля каталог tests/ переносится вместе с ним; inline-тесты требуют ручного извлечения из исходного файла.
  4. Прозрачность ревью: в diff PR изменения исходного кода и тестов — это разные файлы, они не смешиваются.

Стандарты объявления модулей

Правило 2.1: Все тестовые файлы должны иметь модульный doc-комментарий //! в начале, указывающий источник покрытия в спецификации (номер секции спецификации языка + номер RFC). Если тест не ссылается ни на одну секцию спецификации — значит, этот код не имеет обоснования в спецификации, его не должно существовать.

rust
//! Тесты литералов — на основе спецификации языка §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::*.

rust
// 🟢 Хорошо——точный импорт
use crate::frontend::core::lexer::{tokenize, TokenKind};
use crate::frontend::core::parser::{ParserState, ParseError};

// 🔴 Мусор——непонятно, что именно тестируется
use super::*;

Стандарты именования

Правило 3.1: Формат имени тестовой функции: test_<что>_<сценарий>, всё строчными буквами, слова разделены подчёркиванием.

rust
#[test]
fn test_tokenize_empty_string() { /* ... */ }
#[test]
fn test_parse_int_overflow() { /* ... */ }
#[test]
fn test_typecheck_fn_return_mismatch() { /* ... */ }

Правило 3.2: Имя тестовой функции должно быть самодокументирующимся. Прочитав имя, должно быть понятно, что тестируется и что ожидается. Запрещено именование числовыми индексами.

rust
// 🟢 Хорошо
fn test_skip_semicolon_success() { /* ... */ }
fn test_skip_semicolon_failure_when_identifier() { /* ... */ }

// 🔴 Мусор——совершенно непонятно, что тестируется
fn test_skip_1() { /* ... */ }
fn test_skip_2() { /* ... */ }

Правило 3.3: Вспомогательным функциям не нужен префикс test_, они должны описываться глаголом или существительным.

rust
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), секции разделяются пустой строкой.

rust
#[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 и более раз должен быть извлечён во вспомогательную функцию.

rust
// 🟢 Хорошо——извлечён общий 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 автоматически выводит номер строки; но при неудаче внутри вспомогательной функции номер строки указывает на определение вспомогательной функции, а не на место вызова.

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!.

rust
// 🟢 Хорошо
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: Все утверждения должны содержать пользовательское сообщение об ошибке, если само утверждение не описывает причину неудачи.

rust
// 🟢 Хорошо——при неудаче можно быстро локализовать
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] для подключения подмодулей.

rust
// 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::.

rust
// 🟢 Хорошо——через публичный API
use yaoxiang::run;

// 🔴 Мусор——绕过 публичный API
use yaoxiang::middle::codegen::bytecode::BytecodeFile;

Управление тестовыми данными

Правило 8.1: В интеграционных тестах предпочитать inline-строки с исходным кодом. Внешние fixture-файлы использовать только когда исходный код превышает 30 строк (располагать в tests/fixtures/).

rust
#[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: 每个基准函数必须包含模块文档注释 //! 说明测试目的和测量指标。

rust
//! YaoXiang 解释器性能基准测试
//!
//! 测量指标:单次迭代耗时(wall time)
//! 基准线:Rust 原生实现

防止编译器优化

Правило 11.1: 所有基准测试的被测输出必须通过 criterion::black_box 阻止编译器优化消除。

rust
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: 基准测试的输入数据必须是 constlazy_static,不得在 iter 闭包内动态生成——否则测量的是数据生成 + 被测逻辑的总时间。

基准分组与命名

Правило 12.1: 基准测试命名格式为 <被测模块>_<场景>,全小写下划线分隔。与单元测试命名规则一致。

Правило 12.2: 必须使用 criterion_group! 对相关基准进行逻辑分组。禁止所有基准挤在一个分组中。

rust
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 执行。

rust
/// 将源码字符串分词为 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 标记的示例,除非该示例展示的是编译期错误。

rust
/// ```ignore
/// // 展示编译期错误——可以 ignore
/// let x: int = "string";
/// ```

覆盖要求

Правило 14.1: 文档测试覆盖 API 的 happy path 即可。边界情况和错误路径由单元测试覆盖。

Правило 14.2: 文档测试中的示例代码必须简洁——不超过 10 行。如果示例需要更长的上下文,说明 API 设计有问题。


属性测试规范

使用场景

Правило 15.1: 以下场景必须使用属性测试(proptest 或 quickcheck)而不是手写多个边界值用例:

场景示例
解析器 round-tripparse(pretty_print(ast)) == ast
序列化/反序列化deserialize(serialize(data)) == data
数学运算恒等式a + b == b + a
编译器优化不改变语义eval(code) == eval(optimize(code))

Правило 15.2: 属性测试使用 proptest 作为主要的属性测试框架(已在 Cargo.tomldev-dependencies 中声明)。

rust
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: 每个属性测试必须有明确的属性声明——注释中写明验证的不变量。

rust
// 属性:任意整数字面量在 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. 测试命令速查

bash
# 运行所有测试
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 --html

B. 提交信息模板

测试相关提交必须遵循以下模板:

: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. 参考资料


💡 记住:测试不验证你的代码是否"能跑"——它验证你的代码是否符合规范。规范在变,测试跟着规范变。代码写错了,改代码,不要改测试。代码服务于规范,测试守护规范。测试迁就代码的那一刻,你就失去了所有保护。