OxL Справочник OxL
РусскийEnglish

Паттерны тестов

Рецепты этой страницы запускаются командой oxl test файл.oxl. Блоки с test проверяются при сборке сайта выполнением oxl test; виды sim и fuzz отбираются флагом --kind (oxl test).

Табличный тест#

Задача. Проверить функцию на многих входах, чтобы добавление случая стоило одной строки.

oxl✓ проверяется в CI
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) при падении печатает обе стороны, поэтому видно, какой именно случай упал.
  • Второй тест проверяет свойство (идемпотентность) на нескольких значениях; следующий рецепт делает то же на сгенерированных.

Фикстуры#

Задача. Подготавливать данные для теста и гарантированно очищать после него.

oxl✓ проверяется в CI
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 подключает несколько фикстур; каждый тест получает свежее значение.
  • Значение фикстуры неизменяемо для теста; если нужен изменяемый ресурс (БД, временный каталог), фикстура возвращает его, а тест работает с ним через методы.

Мок сервиса#

Задача. Проверить код, который вызывает платёжный сервис, не поднимая сам сервис — и воспроизвести сбои.

oxl✓ проверяется в CI
@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)#

Задача. Зафиксировать вывод сложного форматирования и заметить любое случайное изменение.

oxl✓ проверяется в CI
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, таймауты) без реального ожидания.

oxl✓ проверяется в CI
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), задерживать диск.

Свойства: фаззинг#

Задача. Проверить закономерность на сотнях автоматически сгенерированных входов.

oxl✓ проверяется в CI
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 …), ориентируясь на покрытие кода.
  • Формулируйте свойства, а не примеры: «дважды развёрнутый список равен исходному», «длина сохраняется», «сериализация и разбор возвращают то же».
  • Упавший вход сохраняется и воспроизводится при следующем запуске; подробности — в руководстве по фаззингу.

Проверка ошибок#

Задача. Убедиться, что функция возвращает именно ту ошибку.

oxl✓ проверяется в CI
@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.

Вернуться: кулинарная книга.

См. также

OxL 1.0.0-beta · Версия документации 1.0.0-beta© OxwaldLink · сгенерировано из Стандарта, исходников std и руководства