Паттерны тестов
Рецепты этой страницы запускаются командой oxl test файл.oxl. Блоки с test проверяются при сборке сайта выполнением oxl test;
виды sim и fuzz отбираются флагом --kind (oxl test).
Табличный тест#
Задача. Проверить функцию на многих входах, чтобы добавление случая стоило одной строки.
fn slug(title: str) -> str pure {
var out = String.new()
var dash = false
for c in title.lower().chars() {
if c.isAlpha() || c.isDigit() {
out.pushChar(c)
dash = false
} else if !dash && out.len() > 0 {
out.pushChar('-')
dash = true
}
}
out.freeze().trimSuffix("-")
}
test "slug: table of cases" {
let cases = [
("Hello, World!", "hello-world"),
(" many spaces ", "many-spaces"),
("Already-a-slug", "already-a-slug"),
("", ""),
("2024: the year", "2024-the-year"),
]
for (input, want) in cases {
assert_eq(slug(input), want) // при падении видно и вход, и обе стороны сравнения
}
}
test "slug is idempotent" {
for s in ["Some Title", "x", "A B"] {
assert_eq(slug(slug(s)), slug(s))
}
}- Таблица — обычный список кортежей; цикл
for (input, want) in casesразворачивает его. Отдельного синтаксиса и параметризации не нужно. assert_eq(a, b)при падении печатает обе стороны, поэтому видно, какой именно случай упал.- Второй тест проверяет свойство (идемпотентность) на нескольких значениях; следующий рецепт делает то же на сгенерированных.
Фикстуры#
Задача. Подготавливать данные для теста и гарантированно очищать после него.
data Settings { retries: i32; name: str }
fixture settings() -> Settings { // значение до yield получает тест
let s = Settings{ retries: 3, name: "test" }
yield s
print("fixture cleanup") // код после yield — очистка после теста
}
fixture names() -> List<str> {
yield ["ann", "bob"]
}
test "a test receives the fixture value" with settings as s {
assert_eq(s.retries, 3)
assert_eq(s.name, "test")
}
test "fixtures can be combined" with settings as s, names as ns {
assert_eq(ns.len(), 2)
assert_ne(s.name, ns[0])
}fixture name() -> T { …; yield value; …очистка }— код доyieldготовит значение, код после — очистку (выполняется даже если тест упал).with settings as s, names as nsподключает несколько фикстур; каждый тест получает свежее значение.- Значение фикстуры неизменяемо для теста; если нужен изменяемый ресурс (БД, временный каталог), фикстура возвращает его, а тест работает с ним через методы.
Мок сервиса#
Задача. Проверить код, который вызывает платёжный сервис, не поднимая сам сервис — и воспроизвести сбои.
@error data Declined { why: str }
data Receipt { id: i64 }
service Payments {
@public rpc charge(amount: i64) -> Receipt | Declined
}
impl Payments {
rpc charge(amount) { Receipt{ id: 0 } }
}
fn checkout(amount: i64) -> str {
match Payments.charge(amount) {
Receipt r => "paid #{r.id}"
Declined e => "declined: {e.why}"
NetError => "network error"
}
}
test "retry after a timeout: the mock plays the answers in order" {
mock Payments {
charge(_) => fail Timeout once, then Receipt{ id: 7 } // первый вызов — таймаут, дальше — ответ
}
assert_eq(checkout(100), "network error")
assert_eq(checkout(100), "paid #7")
assert_eq(Payments.calls(.charge).count, 2) // все вызовы записаны
}
test "a mock can reject by argument" {
mock Payments {
charge(a) if a > 500 => fail Declined{ why: "limit" } always
charge(_) => Receipt{ id: 1 } always
}
assert_eq(checkout(1000), "declined: limit")
assert_eq(checkout(10), "paid #1")
}mock Payments { charge(_) => fail Timeout once, then Receipt{ id: 7 } }— сценарий ответов: первый вызов — таймаут, затем успех (последний ответ повторяется).- Образец аргументов может быть с охраной:
charge(a) if a > 500 => … always. Payments.calls(.charge).count— сколько раз и с чем вызывали; так проверяется, что код действительно повторил запрос.- Вызов, не подошедший ни к одной ветке, проваливает тест с описанием вызова — молчаливых «заглушек» нет.
Снимок (snapshot)#
Задача. Зафиксировать вывод сложного форматирования и заметить любое случайное изменение.
data Invoice { id: i64; lines: List<str>; total: i64 }
fn render(inv: &Invoice) -> str {
var out = "invoice #{inv.id}\n"
for l in inv.lines.iter() { out = "{out} - {l}\n" }
"{out}total: {inv.total}"
}
test "invoice layout does not change by accident" {
let inv = Invoice{ id: 12, lines: ["pen x2", "ink x1"], total: 21 }
assert_snapshot(&render(&inv)) // первый запуск создаёт файл snapshots/…, дальше сравнивает
}assert_snapshot(&value)сравнивает"{value}"с файлом вsnapshots/. Первый запуск не проходит: значение записывается в….snap.new.- Просмотрите файл и примите:
oxl test --update-snapshots; файл.snapкладут в репозиторий. Дальше любое отличие — падение с диффом. - Снимки хороши для отчётов, шаблонов и сериализации; для чисел и простых строк надёжнее
assert_eq. - (Пример проверяется командой
oxl check: у теста нет заранее принятого снимка.)
Виртуальное время: симуляция#
Задача. Проверить логику с часами (TTL, таймауты) без реального ожидания.
use std.test { clock }
use std.time { sleep, now }
fn expiresAt(ttl: Duration) -> Time io, time => now() + ttl
test sim "a one-hour TTL passes instantly in virtual time" seeds 1..=5 {
let deadline = expiresAt(1h)
clock.advance(59m)
assert now() < deadline
sleep(2m) // sleep тоже идёт по виртуальным часам
assert now() > deadline
}test sim "…" seeds 1..=5выполняет тело для каждого seed в детерминированной среде: виртуальные часы, планировщик, сеть.clock.advance(59m)и дажеsleep(2m)занимают микросекунды реального времени; результат зависит только от seed.- Упавший seed воспроизводится:
oxl test --kind sim --seed 3. Симуляция умеет также разрывать сеть (net.partition), убивать узлы (node.kill), задерживать диск.
Свойства: фаззинг#
Задача. Проверить закономерность на сотнях автоматически сгенерированных входов.
fn reverse(xs: List<i64>) -> List<i64> pure {
var out: List<i64> = []
for i in 0..xs.len() { out.push(xs[xs.len() - 1 - i]) }
out
}
test fuzz "reversing twice gives the original" (xs: List<i64>) {
assert_eq(reverse(reverse(xs.clone())), xs) // свойство должно выполняться для любого списка
}
test fuzz "reverse keeps the length" (xs: List<i64>) {
let n = xs.len()
assert_eq(reverse(xs).len(), n)
}test fuzz "…" (xs: List<i64>)— параметры генерирует рантайм по их типам (с учётом уточненийwhere …), ориентируясь на покрытие кода.- Формулируйте свойства, а не примеры: «дважды развёрнутый список равен исходному», «длина сохраняется», «сериализация и разбор возвращают то же».
- Упавший вход сохраняется и воспроизводится при следующем запуске; подробности — в руководстве по фаззингу.
Проверка ошибок#
Задача. Убедиться, что функция возвращает именно ту ошибку.
@error data TooShort { min: usize }
@error data NoDigit {}
fn validate(pw: str) -> () | TooShort | NoDigit {
if pw.len() < 8 { fail TooShort{ min: 8 } }
if !pw.chars().iter().any(c => c.isDigit()) { fail NoDigit{} }
}
test "validate reports the right error" {
assert_fails(validate("abc"), TooShort) // assert_fails проверяет тип ошибки
assert_fails(validate("longpassword"), NoDigit)
validate("longpass1")! // без ошибки — обычное значение ()
}assert_fails(выражение, ТипОшибки)проверяет тип; значение без ошибкиvalidate("longpass1")!просто даёт().- Ошибки — значения, поэтому тест не использует перехват исключений: достаточно
matchилиis.
Вернуться: кулинарная книга.