Skip to content

Нормы написания тестов ​

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


Содержание ​


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

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

Настоящие нормы распространяются на весь тестовый код на Rust в проекте YaoXiang, включая:

Тип тестаРасположениеФреймворк
Модульные тестыsrc/<module>/tests/#[test] + #[cfg(test)]
Интеграционные тестыtests/#[test]
Бенчмаркиbenches/Criterion.rs
Документационные тестыКомментарии к APIcargo 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: Нулевая терпимость к случайным сбоям. Тесты должны быть воспроизводимыми в любой среде. Тесты, зависящие от случайных чисел, системного времени или порядка планирования потоков, должны использовать фиксированные seed-значения или заменяться на 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 или интерфейс командной строки
БенчмаркИзмеряет производительность кода, выявляет регрессии производительности
Документационный тестИсполняемые примеры кода, встроенные в комментарии к документации
Property-тестТест, проверяющий инварианты (properties) на основе случайных входных данных

Связь с нормами оформления коммитов ​

Все коммиты, связанные с тестами, должны использовать тип :white_check_mark: test:, см. нормы оформления коммитов.

:white_check_mark: test(parser): добавить тесты инфиксных выражений для парсера Pratt
:white_check_mark: test(codegen): дополнить тесты генерации IR для switch

Уровни тестирования корпусов yx и библиотек ​

Настоящие нормы регулируют тестовый код на стороне Rust. Тесты самого языка YaoXiang (корпуса .yx и тесты библиотек) разделяются по объекту тестирования на два уровня. Дизайн системы и контракты оценки определены в RFC-036 (§7 Сборка сьютов / §8 Три уровня негативных тестов / §9 Многоуровневая архитектура тестирования), а правила оформления корпусов — в tests/yaoxiang/TEST_STANDARDS.md:

  • Корпуса пригодности языка (tests/yaoxiang/) — объект тестирования — сам язык; std используется только как инструмент утверждений. Внутри корпуса, в зависимости от уровня возникновения ошибки, выделяют три типа оценки: тесты поведения / тесты отказа на этапе компиляции / тесты отказа на этапе выполнения
  • Тесты библиотек (идут вместе с библиотекой) — объект тестирования — публичный API-контракт библиотеки; тесты std на уровне yx расположены в src/std/tests/, тесты будущих пользовательских пакетов будут обнаруживаться внутри пакетов через [tool.test]

Формат заголовка тестовых файлов .yx, заголовочные директивы (// expect: / // skip: / // mode:, RFC-036 §8.2) и соглашения об утверждениях определены в TEST_STANDARDS.md. Парсинг оценок реализован в общем для двух runner-ов модуле src/util/test_markers.rs (на стороне Rust, подчиняется настоящим нормам).


Нормы модульных тестов ​

Организация файлов ​

Правило 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
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/

Правила размещения тестов: модуль-файл vs модуль-каталог ​

Ключевое различие: форма организации модуля определяет, где размещаются тесты.

Тип модуляКритерий определенияРасположение тестовПример
Модуль-каталогИмеет собственный каталог и mod.rstests/ в этом каталоге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/              # ❌ Родительский 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. Code Review: в PR diff изменения исходного кода и тестов находятся в разных файлах и не смешиваются.

Нормы объявления модулей ​

Правило 2.1: В верхней части каждого тестового файла должен быть комментарий документации уровня модуля //!, описывающий источник тестового покрытия спецификации (номер раздела спецификации языка + номер 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("why") или собственный panic (см. правило 5.2)
Копипаст одного 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: В интеграционных тестах предпочтительно использовать встроенные строки исходного кода. Внешние файлы fixture (помещаются в tests/fixtures/) используются только тогда, когда исходный код превышает 30 строк.

rust
#[test]
fn test_fibonacci() {
    run_ok(
        r#"
        main: () -> Void = {
            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Корректные входные данные дают ожидаемый вывод
Error pathНекорректные входные данные дают чёткое сообщение об ошибке (не 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: Входные данные бенчмарка должны быть const или lazy_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
/// Разбивает строку исходного кода на последовательность токенов.
///
/// ```
/// 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: Документационные тесты должны покрывать только happy path API. Граничные случаи и пути ошибок покрываются модульными тестами.

Правило 14.2: Примеры кода в документационных тестах должны быть краткими — не более 10 строк. Если пример требует более длинного контекста, это указывает на проблему в дизайне API.


Нормы property-тестов ​

Сценарии использования ​

Правило 15.1: В следующих сценариях необходимо использовать property-тесты (proptest или quickcheck) вместо ручного написания нескольких граничных случаев:

СценарийПример
Round-trip парсераparse(pretty_print(ast)) == ast
Сериализация/десериализацияdeserialize(serialize(data)) == data
Тождества математических операцийa + b == b + a
Оптимизации компилятора не меняют семантикуeval(code) == eval(optimize(code))

Правило 15.2: В качестве основного фреймворка property-тестов используется proptest (уже объявлен в dev-dependencies файла Cargo.toml).

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: Каждый property-тест должен иметь явное объявление свойства — в комментарии указать проверяемый инвариант.

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: Если property-тест обнаруживает сбой, необходимо использовать механизм регрессий 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)
  • Тест проверяет только "отсутствие паники", не утверждая конкретного поведения
  • Удалён упавший тест, выявивший баг в коде (вместо того, чтобы исправить код и увидеть его зелёным)

Приложения ​

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. Справочные материалы ​


💡 Запомните: тест проверяет не то, что ваш код "может работать" — он проверяет, соответствует ли ваш код спецификации. Спецификация меняется, тесты следуют за спецификацией. Если код написан неправильно, исправляйте код, а не тест. Код служит спецификации, тест защищает спецификацию. В тот момент, когда тест начинает подстраиваться под код, вы теряете всю защиту.