Files
slm-design/DRAFT_domains/testing.md

17 KiB
Raw Blame History

Тестирование Domain

Рабочая заметка. Не является нормативным разделом спецификации.

Главный принцип

TST-N001: Тест размещается у владельца проверяемой ответственности

Domain не получает одну общую папку tests/ для всего кода. Business behavior, adapter wiring, preset lifecycle, framework bindings и UI имеют разных владельцев и тестируются рядом с ними.

business behavior → business tests
pure domain rule → colocated business test
adapter behavior → adapter test
preset assembly → preset test
framework lifecycle → framework binding test
UI interaction → UI owner test
cross-domain graph → graph owner test

Матрица покрытия

Граница Предварительная обязательность Что проверяется
Business factory Главная, обязательная Scenarios, state, errors, ports, порядок effects
Public pure business functions Обязательная Validation, normalization, invariants и edge cases
Internal runtime-safe logic По сложности Mappers, guards, parsers, races и branching
Domain error implementation Обязательная при runtime errors Codes, guard, observable fields и source isolation
Adapters Обязательная при наличии Port contract, payload, raw result/error и cleanup
Production presets Обязательная при наличии Wiring, scope, ownership transfer и construction safety
Framework bindings При наличии поведения Provider, hooks, reactivity, lifecycle и hydration
Domain-owned UI При наличии значимого поведения States, interactions и accessibility contract
Cross-domain graph При наличии graph Assembly order, API handoff, scope и cleanup
E2E По продуктовой потребности Полный пользовательский поток

Предварительная структура

domains/auth/
├── business/
│   ├── auth.factory.ts
│   ├── index.ts
│   ├── index.test.ts
│   ├── errors/
│   │   ├── auth-error.ts
│   │   └── auth-error.test.ts
│   ├── lib/
│   │   ├── auth-phone.ts
│   │   └── auth-phone.test.ts
│   ├── services/
│   ├── types/
│   └── tests/
│       └── factory/
│           ├── public-api.test.ts
│           ├── request-phone-otp.test.ts
│           ├── resend-phone-otp.test.ts
│           ├── verify-phone-otp.test.ts
│           └── testing/
│               └── create-auth-test-harness.ts
├── presets/
│   └── {preset-name}/
│       ├── adapters/
│       │   ├── auth-source.adapter.ts
│       │   └── auth-source.adapter.test.ts
│       ├── create-auth.ts
│       ├── create-auth.test.ts
│       └── index.ts
└── {framework-binding}/
    ├── auth.provider.tsx
    ├── auth.provider.test.tsx
    ├── use-auth.ts
    └── use-auth.test.tsx

Это карта возможных тестов, а не обязательный scaffold. Файл создаётся только вместе с реальным поведением, которое требуется проверить.

Business tests

TST-N002: Factory-level tests являются главными тестами Domain behavior

Business factory тестируется как black box через public API business-модуля:

import {
  authFactory,
  AUTH_ERROR_CODES,
  isAuthError,
} from '@/domains/auth/business'

Factory-level tests не зависят от React, Next.js, production SDK, real storage или production presets. Все runtime capabilities заменяются test ports, mocks, stubs или in-memory fakes.

Обязательная матрица для public scenarios:

  • форма возвращаемого API;
  • отсутствие side effects при вызове factory;
  • happy path;
  • input validation;
  • нормализация результатов ports;
  • nullable, empty и malformed results;
  • rejected promise dependency;
  • synchronous throw dependency;
  • stable domain error code;
  • отсутствие raw source error как consumer contract;
  • порядок side effects;
  • остановка следующих effects после failure;
  • state transitions;
  • repeated и concurrent calls, если они влияют на контракт;
  • lifecycle operations и cleanup, если они входят в public business API.

Если business behavior невозможно проверить без React, Vue, Next.js или concrete SDK, это сигнал о проникновении framework/runtime ответственности внутрь business.

TST-N003: Factory-level test использует per-test assembly

Каждый test case создаёт factory с нужной именно ему конфигурацией ports:

it('maps source failure to domain error', async () => {
  const cause = new Error('Network failed')
  const requestCode = vi.fn().mockRejectedValue(cause)
  const { api } = createAuthTestHarness({ requestCode })

  await expect(api.requestPhoneOtp(phone)).rejects.toMatchObject({
    code: AUTH_ERROR_CODES.PHONE_OTP_REQUEST_FAILED,
  })
})

Другой test case создаёт независимую assembly:

it('does not call source for invalid phone', async () => {
  const requestCode = vi.fn()
  const { api } = createAuthTestHarness({ requestCode })

  await expect(api.requestPhoneOtp('123')).rejects.toMatchObject({
    code: AUTH_ERROR_CODES.PHONE_OTP_PHONE_INVALID,
  })

  expect(requestCode).not.toHaveBeenCalled()
})

TST-N004: Test harness не является preset

Test harness является private test utility, которая уменьшает boilerplate и предоставляет observability:

const { api, ports, state } = createAuthTestHarness(overrides)

Test harness:

  • private для конкретной test suite;
  • не экспортируется production entrypoint;
  • допускает произвольные scenario-specific overrides;
  • создаёт новый API instance для каждого test case;
  • не представляет устойчивую application environment;
  • не имеет собственного production lifecycle;
  • не размещается в presets/.

Общий test preset по умолчанию не создаётся. Если Storybook, demo application или e2e environment получают устойчивую именованную конфигурацию, это отдельный application preset, а не универсальная конфигурация unit tests.

Предварительное имя helper:

business/tests/factory/testing/create-auth-test-harness.ts

Public API tests

TST-N005: Business public API проверяется отдельно

Runtime public exports фиксируются тестом entrypoint:

import * as authBusiness from '.'

expect(Object.keys(authBusiness).sort()).toEqual([
  'AUTH_ERROR_CODES',
  'authFactory',
  'isAuthError',
  'normalizeAuthPhone',
  'validateAuthPhone',
])

Этот тест обнаруживает случайный runtime export, но не видит type-only exports. Полная проверка type surface должна выполняться будущим architecture lint или TypeScript API check.

Форма API instance также фиксируется factory-level test:

expect(Object.keys(authFactory(ports)).sort()).toEqual([
  'requestPhoneOtp',
  'resendPhoneOtp',
  'signOut',
  'verifyPhoneOtp',
])

Pure domain functions

TST-N006: Pure functions тестируются рядом с реализацией

business/lib/auth-phone.ts
business/lib/auth-phone.test.ts

Проверяются:

  • canonical values;
  • boundary values;
  • malformed input;
  • normalization;
  • invariants;
  • отсутствие mutation входа;
  • детерминированность результата.
describe('normalizeAuthPhone', () => {
  it.each([
    ['8 (999) 111-22-33', '+79991112233'],
    ['+7 999 111 22 33', '+79991112233'],
    ['123', null],
  ])('normalizes %s', (input, expected) => {
    expect(normalizeAuthPhone(input)).toBe(expected)
  })
})

Business scenario повторно применяет то же правило на своей границе. UI validation не заменяет business validation.

Internal tests

TST-N007: Colocated tests дополняют public contract tests

Colocated tests оправданы для:

  • mappers и normalizers;
  • runtime guards и parsers;
  • private error implementation;
  • сложного branching;
  • race/concurrency algorithms;
  • reusable internal pure functions.

Отдельный test каждого service не требуется автоматически. Factory-level tests остаются главным доказательством, что внутренняя реализация подключена к public scenario правильно.

Service test добавляется, если он существенно упрощает проверку сложного внутреннего алгоритма и не дублирует целиком factory-level matrix.

Domain errors

TST-N008: Consumer contract ошибки тестируется без public constructor

Factory-level test проверяет observable contract:

try {
  await api.verifyPhoneOtp(data)
} catch (error) {
  expect(isAuthError(error)).toBe(true)

  if (isAuthError(error)) {
    expect(error.code).toBe(
      AUTH_ERROR_CODES.PHONE_OTP_VERIFY_CODE_INVALID,
    )
  }
}

Consumer-level test не использует private AuthBusinessError constructor и не зависит от instanceof internal class.

Colocated test error implementation может отдельно проверить:

  • private constructor;
  • cause;
  • source code mapping;
  • source metadata normalization;
  • защиту от malformed error values.

Adapter tests

TST-N009: Adapter test проверяет port boundary, а не business behavior

Adapter test размещается рядом с adapter и проверяет:

  • правильную concrete operation;
  • transport payload;
  • преобразование domain arguments в concrete arguments;
  • raw/unknown result согласно port contract;
  • проброс source error без создания domain error;
  • subscription cleanup;
  • отсутствие лишних SDK operations в минимальном client;
  • environment boundary, если она проверяема build/lint средствами.

Adapter test не повторяет domain error mapping, business fallback и scenario orchestration.

Если несколько adapters реализуют один нетривиальный behavioral port contract, позднее можно выделить reusable contract test suite. Она остаётся test-only utility и не становится preset.

Preset tests

TST-N010: Production preset test проверяет assembly risk

Preset test размещается рядом с production preset и проверяет:

  • выбор правильных adapters;
  • передачу полного Deps в factory;
  • exact narrowed API view, если preset его задаёт;
  • отсутствие I/O при construction;
  • отсутствие import-time subscriptions и storage reads;
  • scope API instance;
  • передачу lifecycle/dispose handles caller;
  • изоляцию двух request-scoped instances;
  • server/client import boundary.

Preset test не повторяет happy path и error matrix business scenarios. Эти гарантии принадлежат factory-level tests.

Framework binding tests

TST-N011: Framework binding тестируется через fake business API

Framework unit test по умолчанию получает fake API, а не собирает реальную factory:

const authApi = createAuthApiFake()

render(
  <AuthProvider api={authApi}>
    <Consumer />
  </AuthProvider>,
)

Проверяются:

  • Provider предоставляет переданный instance;
  • access hook возвращает правильный API;
  • использование без Provider даёт предсказуемую ошибку;
  • изменение framework-neutral state вызывает framework update;
  • subscriptions запускаются в правильной lifecycle phase;
  • cleanup выполняется после unmount;
  • Strict Mode не запускает construction side effects;
  • server snapshot и hydration согласованы, если binding участвует в SSR.

Отдельный smoke test с real factory и memory ports добавляется только при самостоятельном integration risk. Такой тест принадлежит framework module либо graph owner, который действительно собирает эту связку.

UI tests

TST-N012: Domain UI тестируется при наличии значимого поведения

Компонент не требует test только потому, что он существует. Test оправдан, если Domain-owned UI:

  • содержит interaction;
  • отображает несколько domain states;
  • реагирует на domain error code;
  • управляет focus или keyboard navigation;
  • имеет значимый accessibility contract;
  • использует framework lifecycle;
  • содержит регрессионно опасную presentation logic.

Проверяются observable behavior и accessibility semantics, а не внутренняя структура JSX/Vue template.

Snapshot-only tests не являются обязательным доказательством. Визуальные различия при необходимости проверяются отдельным visual regression инструментом.

Universal UI module тестируется в слое ui, а page/screen/composition UI тестируется у соответствующего composition owner. Наличие React/Vue само по себе не переносит ownership теста в Domain.

Graph и E2E tests

TST-N013: Cross-domain graph тестируется у graph owner

Проверяются:

  • topological assembly order;
  • передача собранных API в dependent factories;
  • exact graph type;
  • отсутствие повторной assembly без нужного scope;
  • ownership instance;
  • lifecycle start и cleanup;
  • request/application/page isolation.

Business modules не содержат tests полного application graph.

TST-N014: E2E дополняет, но не заменяет Domain tests

E2E проверяет пользовательский поток через реальный application entry. Он не заменяет factory-level tests, потому что не способен дешёво и детерминированно перебрать malformed responses, synchronous throws, races и все domain error mappings.

Чего избегать

TST-N015: Test suite не повторяет одну ответственность на всех уровнях

Не рекомендуется:

  • повторять одну scenario matrix в service, factory, preset и framework tests;
  • тестировать business через production SDK;
  • использовать общий mutable API instance между tests;
  • экспортировать test harness из production public API;
  • создавать presets/testing как default-механизм unit tests;
  • проверять private implementation из factory-level tests;
  • считать type-only файл требующим runtime unit test;
  • использовать real network или process env в business tests.

Минимальная правильная граница предпочтительнее большого количества дублирующих tests.