
Clean Code Tests
- 35 installs
- 2 repo stars
- Updated July 17, 2026
- ontoledgy/ol_ai_context_library
Helps with testing & qa tasks.
About
clean-code-tests is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted coding.
- clean-code-tests
- Testing & QA
- AI-coding skill
Clean Code Tests by the numbers
- 35 all-time installs (skills.sh)
- Ranked #1,312 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ontoledgy/ol_ai_context_library --skill clean-code-testsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 35 |
|---|---|
| repo stars | ★ 2 |
| Last updated | July 17, 2026 |
| Repository | ontoledgy/ol_ai_context_library ↗ |
What it does
Helps with testing & qa tasks.
Files
Clean Code Tests
Role
You are a test engineer. You generate tests that meet project quality standards, review existing tests against those standards, and identify gaps in test coverage.
You do NOT implement production code. You write test code only, and flag production code issues as recommendations for [language]-data-engineer Implement Mode.
---
Input
| Parameter | Required | Description |
|---|---|---|
mode | Yes | generate \ |
target_path | Yes | File, class, or function to generate/review/check tests for |
language | Yes | python \ |
test_category | No | unit (default) \ |
test_path | No (review, coverage-check) | Path to existing test file(s); inferred if omitted |
standard | No | general (default) \ |
standard defaults to general when omitted. Set standard: ob for BORO/Ontoledgy codebases.
---
Standard Definitions
| Value | Convention Set | Source |
|---|---|---|
general | Clean Code (Robert C. Martin) | prompts/coding/standards/clean_coding/ + references/testing-standards.md |
ob (Python) | BORO Quick Style Guide + Clean Code base | skills/ob-engineer/references/boro-quick-style-guide.md layered on top of general; OB wins on conflicts |
ob (Rust) | BORO Quick Style Guide (Rust) + Clean Code base | skills/ob-engineer/references/boro-quick-style-guide-rust.md layered on top of general; OB wins on conflicts |
When standard=ob, tests are generated and reviewed against OB conventions in addition to the general testing standards. Load the language-appropriate OB guide: Python guide for Python, Rust guide for Rust. OB mode supports Python and Rust. If standard=ob is set with an unsupported language, warn and fall back to general.
OB Overrides for Tests (Python)
OB mode applies BORO conventions to test code itself:
| Category | OB Rule for Tests |
|---|---|
| Naming | Test function names use action verbs; no vague names (data, tmp, process); is_/has_ prefix for boolean helpers; __double_underscore for private test helpers |
| Layout | 20-char line length; each arg on own line; type annotations on all test helper signatures; named params with * for helpers with > 1 param |
| Strings | Single quotes only; no hardcoded strings in assertions — use constants for expected values where the string represents domain vocabulary |
| Structure | One test class per file (aligns with one public function per file); test helpers as __private functions in the test file |
| Error assertions | Test for specific exception types only (matching the specific-exceptions-only production rule) |
| Imports | Explicit only (from file import name); no *; no folder imports |
| Comments | None except # TODO — test names must be self-documenting |
OB Overrides for Tests (Rust)
| Category | OB Rule for Tests |
|---|---|
| Naming | Test function names use action verbs (test_export_returns_records_when_valid); no vague names; is_/has_ prefix for boolean helpers; no single-letter variables except self |
| Layout | 20-char line length; each arg on own line; type annotations on test helper signatures; explicit -> () on test functions |
| Strings | No hardcoded strings in assertions — use const for expected values where the string represents domain vocabulary |
| Structure | #[cfg(test)] mod tests block per source file; test helpers as private fn (no pub) within the test module; builder/make_* functions for fixtures |
| Types | Test fixture structs use named fields (no tuple structs); #[derive(Debug)] on all test types |
| Error assertions | assert_matches! for specific error variants; never match on error message strings — match on enum variants |
| Ownership | Prefer borrowing in test helpers; .clone() acceptable in test setup for readability but not as a default |
| Imports | Explicit use — glob import of the parent module (use super::*) is the only permitted exception |
| Comments | None except // TODO — test names must be self-documenting |
---
Standards Loaded in All Modes
Always load:
references/testing-philosophy.md— F.I.R.S.T., TDD, why clean tests matterreferences/testing-standards.md— coverage, naming, AAA, fixtures, mocking, markers, anti-patterns
Always load the language-specific reference:
references/languages/[language].md— framework, tooling, and idioms for the target language
If standard=ob, also load the language-appropriate BORO Quick Style Guide:
- Python:
skills/ob-engineer/references/boro-quick-style-guide.md - Rust:
skills/ob-engineer/references/boro-quick-style-guide-rust.md
OB rules override general where they conflict. Apply OB conventions to the test code itself (see the language-appropriate OB Overrides for Tests table above).
---
Mode: generate
Generate tests for the class or function at target_path. Produces a complete test file.
Workflow
Step 1 — Read standards
Load all three references listed above before writing a single line of test code.
Step 2 — Read the target code
Read target_path completely. Identify:
- Public interface: all public functions/methods with their signatures and return types
- Pre-conditions: inputs that are validated/rejected
- Post-conditions: what the function guarantees on success
- Error paths: exceptions raised, edge conditions
Step 3 — Plan test cases
For each public function, plan:
| Category | What to cover |
|---|---|
| Happy path | One test per distinct valid input shape |
| Boundary conditions | Min/max values, empty collections, zero, null/None |
| Error conditions | Each exception type; invalid inputs; pre-condition violations |
| Edge cases | Single-element collections, large inputs, special characters |
Step 4 — Write tests
Apply the standards from references/testing-standards.md and the language idioms from references/languages/[language].md. Do not invent patterns — use only what the references define.
Step 5 — Produce test file
Output (generate)
## Test Generation — [target_path]
**Language:** [language]
**Standard:** [general | ob]
**Category:** [unit | integration]
**Tests generated:** [N]
**Coverage of public interface:** [functions covered / total functions]
---
[Full test file content]
---
### Test Case Summary
| Test Name | What It Covers | Category |
|-----------|---------------|----------|
---
### Untested Paths
[Any paths not covered and why — e.g. private methods, external dependencies]---
Mode: review
Review existing tests at test_path against quality standards. Produces an annotated report.
Workflow
Step 1 — Read standards
Load all three references listed above.
Step 2 — Read production code and test code
Read target_path (production) and test_path (tests). Understand what the production code does before assessing how the tests cover it.
Step 3 — Apply the compliance checklist
Work through the checklist in references/testing-standards.md. For each violation:
- Record exact file and line number
- Name the rule violated
- Assign severity (HIGH / MEDIUM / LOW)
- Write a specific, actionable suggested fix
Severity criteria:
| Severity | Criteria |
|---|---|
| HIGH | Hides real behaviour; test passes when it should fail; tests the mock not the code |
| MEDIUM | Reduces clarity or makes the test fragile; names don't reveal intent |
| LOW | Minor style issue; not a correctness risk |
Step 4 — Produce review report
Output (review)
## Test Review — [test_path]
**Language:** [language]
**Standard:** [general | ob]
**Category:** [unit | integration]
**Tests reviewed:** [N]
**Violations:** [N] (HIGH: N, MEDIUM: N, LOW: N)
---
### Violations
| # | Test | Line | Rule | Severity | Description | Suggested Fix |
|---|------|------|------|----------|-------------|---------------|
---
### Verdict
**[APPROVE / REQUEST CHANGES / REJECT]**
[1–2 sentence summary]---
Mode: coverage-check
Identify paths in the production code at target_path that have no corresponding tests.
Workflow
Step 1 — Read standards
Load all three references listed above.
Step 2 — Map production code paths
Read target_path. For each public function, enumerate all logical paths:
- Normal path
- Each conditional branch
- Each exception raised
- Edge cases visible from the signature (empty input, zero, None/null)
Step 3 — Read existing tests
Read test_path (or inferred location). Map each test to the path(s) it exercises.
Step 4 — Identify gaps
Mark each production path as covered or uncovered. Flag paths with:
- No test at all
- Only happy-path coverage (no error or edge coverage)
- Mocked-away behaviour that should be integration-tested
Step 5 — Produce gap analysis
Output (coverage-check)
## Coverage Gap Analysis — [target_path]
**Language:** [language]
**Standard:** [general | ob]
**Category:** [unit | integration]
**Functions analysed:** [N]
**Paths covered:** [N] / [Total paths]
**Coverage estimate:** [N]%
---
### Covered Paths
| Function | Path | Covered by |
|----------|------|-----------|
---
### Uncovered Paths
| Function | Uncovered Path | Risk | Recommended Test Name |
|----------|---------------|------|----------------------|
---
### Recommended Actions
1. [Highest priority gaps with specific test case suggestions]Clean Code Tests — C#
Language-specific testing patterns for C# (.NET 8+). Read alongside references/testing-philosophy.md and references/testing-standards.md.
---
Framework and Tooling
| Tool | Purpose |
|---|---|
xUnit | Preferred test framework — [Fact], [Theory], IClassFixture |
Moq | Mocking library — Mock<T>, .Setup(), .Verify() |
FluentAssertions | Readable assertions — .Should().Be() |
Microsoft.AspNetCore.Mvc.Testing | Integration testing for ASP.NET Core |
Testcontainers | Docker-based external services in integration tests |
dotnet test | Test runner |
---
File and Class Structure
// tests/UnitTests/Module/ComponentNameTests.cs
using FluentAssertions;
using Moq;
using Xunit;
namespace MyProject.Tests.UnitTests.Module;
public class ComponentNameTests
{
private readonly ComponentName _sut;
private readonly Mock<IExternalService> _mockService;
public ComponentNameTests()
{
_mockService = new Mock<IExternalService>();
_sut = new ComponentName(_mockService.Object);
}
[Fact]
public void Process_ValidInput_ReturnsExpectedOutput()
{
// Arrange
var input = new InputData { Key = "value" };
var expected = new OutputData { Processed = true, Key = "value" };
// Act
var result = _sut.Process(input);
// Assert
result.Should().BeEquivalentTo(expected);
}
[Fact]
public void Process_NullInput_ThrowsArgumentNullException()
{
// Arrange & Act
var act = () => _sut.Process(null!);
// Assert
act.Should().Throw<ArgumentNullException>()
.WithMessage("*input*");
}
}Rules:
- Test class named
<ComponentName>Tests - Subject under test stored in
_sut(System Under Test) - Constructor for per-test setup;
IClassFixture<T>for shared expensive setup private readonlyfields for sut and mocks- Namespace mirrors source:
MyProject.Tests.UnitTests.Module
---
Naming
| Unit | Pattern | Example |
|---|---|---|
| Test class | <ComponentName>Tests | TransactionLoaderTests |
| Test method | <Method>_<StateUnderTest>_<ExpectedBehaviour> | Load_MissingFile_ThrowsFileNotFoundException |
| Fixture class | <Feature>Fixture | DatabaseFixture |
---
Fixtures and Shared Setup
Per-test setup (constructor / IAsyncLifetime)
public class TransactionLoaderTests
{
private readonly TransactionLoader _sut;
public TransactionLoaderTests()
{
// Runs before each test
_sut = new TransactionLoader();
}
}Shared expensive setup (IClassFixture<T>)
public class DatabaseFixture : IAsyncLifetime
{
public TestDatabase Database { get; private set; } = null!;
public async Task InitializeAsync()
{
Database = await TestDatabase.CreateAsync();
}
public async Task DisposeAsync()
{
await Database.DisposeAsync();
}
}
public class RepositoryIntegrationTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public RepositoryIntegrationTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task Save_ValidRecord_PersistsToDatabase()
{
// Arrange
var repository = new TransactionRepository(_fixture.Database.ConnectionString);
var record = new Transaction { Amount = 100m };
// Act
await repository.SaveAsync(record);
// Assert
var saved = await repository.FindAsync(record.Id);
saved.Should().NotBeNull();
saved!.Amount.Should().Be(100m);
}
}---
Mocking with Moq
[Fact]
public void Process_CallsFormatterOnce_WithRawRecord()
{
// Arrange
var mockFormatter = new Mock<IRecordFormatter>();
mockFormatter
.Setup(f => f.Format(It.IsAny<RawRecord>()))
.Returns(new FormattedRecord { Label = "formatted" });
var sut = new RecordProcessor(mockFormatter.Object);
var rawRecord = new RawRecord { Data = "raw" };
// Act
sut.Process(rawRecord);
// Assert
mockFormatter.Verify(
f => f.Format(It.Is<RawRecord>(r => r.Data == "raw")),
Times.Once);
}---
Error Path Testing
[Fact]
public void Load_MissingFilePath_ThrowsFileNotFoundException()
{
// Arrange
var invalidPath = "/nonexistent/file.csv";
// Act
var act = () => _sut.Load(invalidPath);
// Assert
act.Should().Throw<FileNotFoundException>()
.WithMessage($"*{invalidPath}*");
}
[Fact]
public async Task FetchAsync_ApiUnavailable_ThrowsHttpRequestException()
{
// Arrange
_mockHttpClient
.Setup(c => c.GetAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Connection refused"));
// Act
var act = async () => await _sut.FetchAsync("resource-id");
// Assert
await act.Should().ThrowAsync<HttpRequestException>()
.WithMessage("*Connection refused*");
}---
Parametrised Tests ([Theory] + [InlineData])
[Theory]
[InlineData("hello", "HELLO")]
[InlineData("world", "WORLD")]
[InlineData("", "" )]
public void Transform_ReturnsUppercasedString(string input, string expected)
{
var result = _sut.Transform(input);
result.Should().Be(expected);
}
// MemberData for complex objects
public static IEnumerable<object[]> InvalidPaths =>
[
["../etc/passwd", "path traversal"],
["/absolute", "absolute path" ],
];
[Theory]
[MemberData(nameof(InvalidPaths))]
public void Load_UnsafePath_ThrowsArgumentException(string path, string expectedFragment)
{
var act = () => _sut.Load(path);
act.Should().Throw<ArgumentException>()
.WithMessage($"*{expectedFragment}*");
}---
Async Tests
[Fact]
public async Task FetchRecordAsync_ValidId_ReturnsRecord()
{
// Arrange
var expectedRecord = new Transaction { Id = "1", Amount = 100m };
_mockRepository
.Setup(r => r.FindAsync("1", It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedRecord);
// Act
var result = await _sut.FetchRecordAsync("1");
// Assert
result.Should().NotBeNull();
result!.Amount.Should().Be(100m);
}
[Fact]
public async Task FetchRecordAsync_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
// Act
var act = async () => await _sut.FetchRecordAsync("1", cts.Token);
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
}Rules:
- All async test methods return
Task, neverasync void - Always pass and test
CancellationTokenfor cancellable operations - Never use
.Resultor.Wait()— alwaysawait
---
Integration Tests
[Trait("Category", "Integration")]
public class CsvPipelineIntegrationTests : IClassFixture<TempDirectoryFixture>
{
private readonly TempDirectoryFixture _fixture;
public CsvPipelineIntegrationTests(TempDirectoryFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task RunPipeline_ValidCsvInput_ProducesExpectedOutput()
{
// Arrange
var inputPath = Path.Combine(_fixture.InputDirectory, "transactions.csv");
File.WriteAllText(inputPath, "id,amount\n1,100\n2,200");
var outputPath = Path.Combine(_fixture.OutputDirectory, "result.json");
// Act
await Pipeline.RunAsync(inputPath, outputPath);
// Assert
var output = await File.ReadAllTextAsync(outputPath);
output.Should().Contain("\"amount\":100");
output.Should().Contain("\"amount\":200");
}
}---
Anti-Patterns (C# Specific)
| Anti-pattern | Why |
|---|---|
async void test method | Exceptions are swallowed; always return Task |
.Result or .Wait() | Can deadlock; always await |
new Mock<ConcreteClass>() | Can't mock concrete types without virtual methods; use interfaces |
Asserting != null only | Weak; use .Should().NotBeNull().And.BeEquivalentTo(expected) |
Thread.Sleep in tests | Flaky; use Task.Delay with cancellation or proper async awaiting |
Clean Code Tests — JavaScript / TypeScript
Language-specific testing patterns for JavaScript and TypeScript. Read alongside references/testing-philosophy.md and references/testing-standards.md.
---
Framework and Tooling
| Tool | Purpose |
|---|---|
vitest | Preferred test runner (fast, ESM-native, Vite-integrated) |
jest | Alternative runner (CommonJS projects, existing jest setups) |
@testing-library/react | React component testing |
@testing-library/user-event | Simulating user interactions |
vi.fn() / jest.fn() | Mock functions |
msw | Mock Service Worker — intercept HTTP at network level |
---
File and Class Structure
// tests/unit/module/component.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ComponentName } from '../../../src/module/component';
describe('ComponentName', () => {
let component: ComponentName;
beforeEach(() => {
component = new ComponentName();
});
describe('process', () => {
it('returns expected output for valid input', () => {
// Arrange
const input = { key: 'value' };
const expected = { processed: true, key: 'value' };
// Act
const result = component.process(input);
// Assert
expect(result).toEqual(expected);
expect(result.processed).toBe(true);
});
it('throws TypeError when input is null', () => {
expect(() => component.process(null)).toThrow(
new TypeError('Input cannot be null'),
);
});
});
});Rules:
- Use
describeto group by class/function, nesteddescribefor method - Use
it(nottest) for individual cases — reads as a sentence beforeEach/afterEachfor setup and teardownbeforeAll/afterAllonly for truly expensive shared resources (DB connections)
---
Naming
| Unit | Pattern | Example |
|---|---|---|
Outer describe | component or module name | describe('TransactionLoader', ...) |
Inner describe | method or function name | describe('load', ...) |
it / test | reads as sentence | it('throws FileNotFoundError when path is missing', ...) |
Full sentence when combined: TransactionLoader > load > throws FileNotFoundError when path is missing
---
Mocking
vi.fn() — mock a function
it('calls the formatter with the raw record', () => {
// Arrange
const mockFormatter = vi.fn().mockReturnValue({ formatted: true });
const loader = new TransactionLoader({ formatter: mockFormatter });
// Act
loader.load(rawRecord);
// Assert
expect(mockFormatter).toHaveBeenCalledOnce();
expect(mockFormatter).toHaveBeenCalledWith(rawRecord);
});vi.spyOn() — spy on an existing method
it('logs an error when processing fails', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
component.process(invalidInput);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('processing failed'));
errorSpy.mockRestore();
});Module mocking
vi.mock('../../../src/services/apiClient', () => ({
fetchRecord: vi.fn().mockResolvedValue({ id: '1', amount: 100 }),
}));
import { fetchRecord } from '../../../src/services/apiClient';
it('loads a record from the API', async () => {
const result = await loadRecord('1');
expect(fetchRecord).toHaveBeenCalledWith('1');
expect(result.amount).toBe(100);
});---
Error Path Testing
it('throws RecordNotFoundError when record does not exist', () => {
expect(() => service.find('missing-id')).toThrow(RecordNotFoundError);
expect(() => service.find('missing-id')).toThrow('Record not found: id=missing-id');
});
it('rejects with NetworkError when API is unreachable', async () => {
mockFetch.mockRejectedValue(new NetworkError('timeout'));
await expect(service.fetchRemote('id')).rejects.toThrow(NetworkError);
});---
Parametrised Tests (test.each)
it.each([
['hello', 'HELLO'],
['world', 'WORLD'],
['', '' ],
])('transform("%s") returns "%s"', (input, expected) => {
expect(component.transform(input)).toBe(expected);
});
// Object form — preferred for readability
it.each([
{ path: '../etc/passwd', expectedError: 'path traversal' },
{ path: '/absolute', expectedError: 'absolute path' },
])('load rejects unsafe path "$path"', ({ path, expectedError }) => {
expect(() => loader.load(path)).toThrow(expectedError);
});---
Async Tests
it('fetches a record successfully', async () => {
// Arrange
mockApiClient.get.mockResolvedValue({ id: '1', amount: 100 });
// Act
const result = await service.fetchRecord('1');
// Assert
expect(result.amount).toBe(100);
});
it('throws on API timeout', async () => {
mockApiClient.get.mockRejectedValue(new Error('Request timed out'));
await expect(service.fetchRecord('1')).rejects.toThrow('Request timed out');
});---
React Component Testing
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TransactionList } from '../../../src/components/TransactionList';
describe('TransactionList', () => {
it('renders all transactions', () => {
// Arrange
const transactions = [
{ id: '1', amount: 100, label: 'Coffee' },
{ id: '2', amount: 250, label: 'Lunch' },
];
// Act
render(<TransactionList transactions={transactions} />);
// Assert
expect(screen.getByText('Coffee')).toBeInTheDocument();
expect(screen.getByText('Lunch')).toBeInTheDocument();
});
it('calls onDelete with the correct id when delete button is clicked', async () => {
// Arrange
const onDelete = vi.fn();
const transactions = [{ id: '1', amount: 100, label: 'Coffee' }];
render(<TransactionList transactions={transactions} onDelete={onDelete} />);
// Act
await userEvent.click(screen.getByRole('button', { name: /delete/i }));
// Assert
expect(onDelete).toHaveBeenCalledWith('1');
});
it('displays empty state when no transactions provided', () => {
render(<TransactionList transactions={[]} />);
expect(screen.getByText('No transactions')).toBeInTheDocument();
});
});Rules:
- Query by role or label text, not by CSS class or test ID unless unavoidable
userEventoverfireEvent— simulates real browser interactions- Never test implementation details (internal state, private methods)
- Test what the user sees and does, not how the component is built
---
TypeScript Specific
// Type assertions in tests — use satisfies or as, not any
const result = component.process(input) satisfies ProcessedRecord;
// Typed mock return values
const mockService = {
fetch: vi.fn<[string], Promise<Record>>().mockResolvedValue(testRecord),
};
// Never use any in test files — use unknown with narrowing or explicit types
const rawResponse: unknown = await apiClient.get('/endpoint');
expect(rawResponse).toMatchObject({ status: 'ok' });---
vitest Configuration Reference
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node', // or 'jsdom' for DOM/React tests
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
thresholds: { lines: 80, branches: 75 },
},
},
});Clean Code Tests — Python
Language-specific testing patterns for Python (pytest). Read alongside references/testing-philosophy.md and references/testing-standards.md.
---
Framework and Tooling
| Tool | Purpose |
|---|---|
pytest | Test runner and fixture system |
unittest.mock | Mocking — MagicMock, patch, call |
pytest-anyio | Async test runner — @pytest.mark.anyio |
pytest-cov | Coverage reporting |
testcontainers | Docker-based external services in integration tests |
---
File and Class Structure
"""Test module for ComponentName functionality."""
from __future__ import annotations # always first
from collections.abc import Iterator
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, call, patch
import pytest
from src.module.component import ComponentName
class TestComponentName:
"""Test suite for ComponentName.
Covers initialisation, processing, and error handling.
"""
@pytest.fixture(autouse=True)
def setup(self) -> None:
"""Set up a fresh ComponentName before each test."""
self.component = ComponentName()
def test_process_valid_input_returns_expected_output(self) -> None:
"""Test that valid input is processed correctly."""
# Arrange
input_data = {"key": "value"}
expected = {"processed": True, "key": "value"}
# Act
result = self.component.process(input_data)
# Assert
assert result == expected
assert result["processed"] is True
def test_process_none_input_raises_value_error(self) -> None:
"""Test that None input raises ValueError with descriptive message."""
with pytest.raises(ValueError, match="Input cannot be None"):
self.component.process(None)Rules:
- Every test method annotated
-> None from __future__ import annotationsalways first- One test class per source class
autouse=Truefixture for per-test setup
---
Naming
| Unit | Pattern | Example |
|---|---|---|
| Test class | Test<ComponentName> | TestTransactionLoader |
| Test method | test_<action>_<condition>_<result> | test_load_raises_file_not_found_when_path_missing |
| Fixture | descriptive noun | temp_output_folder, mock_registry |
---
Fixtures
Scope and cleanup
@pytest.fixture(scope="session")
def data_input_folder() -> Path:
"""Absolute path to the test input data directory."""
return Path(__file__).parent / "data" / "input"
@pytest.fixture
def temp_output_folder() -> Iterator[Path]:
"""Temporary output folder; removed after each test."""
import shutil, tempfile
temp_dir = Path(tempfile.mkdtemp(prefix="test_output_"))
try:
yield temp_dir
finally:
shutil.rmtree(temp_dir, ignore_errors=True)Fixture composition
@pytest.fixture
def snapshot_configuration(
test_input_folder: Path,
test_output_folder: Path,
) -> SnapshotConfiguration:
"""Fully wired snapshot configuration for tests."""
return SnapshotConfiguration(
input_folder=test_input_folder,
output_folder=test_output_folder,
)Shared fixtures
Place shared fixtures in tests/fixtures/<category>.py and import with wildcard in conftest.py:
# conftest.py
from tests.fixtures.paths import *
from tests.fixtures.configurations import *---
Mocking
@patch decorator
@patch("src.module.component.OpenAiClient")
def test_generates_summary_calls_api_once(mock_client: MagicMock) -> None:
"""Test that summary generation calls the API exactly once."""
mock_client.return_value.generate.return_value = "summary text"
result = generate_summary("input", client=mock_client.return_value)
mock_client.return_value.generate.assert_called_once()
assert result == "summary text"monkeypatch for method injection
def test_pipeline_service_called(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test that PipelineService.generate is called with the correct config."""
called: dict[str, Any] = {}
def fake_generate(config: dict[str, Any], out: str) -> str:
called["args"] = (config, out)
return str(tmp_path / "out")
monkeypatch.setattr(PipelineService, "generate", staticmethod(fake_generate))
run_pipeline(config={}, output_path=str(tmp_path))
assert "args" in calledVerifying mock interactions
mock_registry = MagicMock()
register_items(registry=mock_registry, items=[item_a, item_b])
assert mock_registry.register.call_count == 2
mock_registry.register.assert_any_call(item=item_a)---
Error Path Testing
def test_load_raises_file_not_found_when_path_missing(self) -> None:
"""Test that missing path raises FileNotFoundError."""
with pytest.raises(FileNotFoundError, match="No such file"):
self.loader.load(path=Path("/nonexistent/file.csv"))
def test_connect_raises_auth_error_on_invalid_credentials(self) -> None:
"""Test that invalid credentials raise AuthenticationError."""
with pytest.raises(AuthenticationError, match="Invalid credentials"):
self.client.connect(username="bad", password="bad")---
Parametrised Tests
@pytest.mark.parametrize(
("input_value", "expected"),
[
("hello", "HELLO"),
("world", "WORLD"),
("", "" ),
],
)
def test_transform_uppercases_input(self, input_value: str, expected: str) -> None:
"""Test that transform uppercases any string input."""
assert self.component.transform(input_value) == expected
@pytest.mark.parametrize(
("bad_path", "expected_error"),
[
("../etc/passwd", "path traversal"),
("/absolute/path", "absolute path"),
],
)
def test_load_rejects_unsafe_paths(self, bad_path: str, expected_error: str) -> None:
"""Test that unsafe paths are rejected with descriptive errors."""
with pytest.raises(ValueError, match=expected_error):
self.loader.load(path=bad_path)---
Async Tests
import pytest
@pytest.mark.anyio
async def test_async_fetch_returns_result(self) -> None:
"""Test that async fetch returns a non-empty result."""
result = await self.service.fetch("resource_id")
assert result is not None
@pytest.mark.anyio
async def test_async_concurrent_operations_complete(self) -> None:
"""Test that concurrent operations all complete without error."""
results = await self.service.fetch_all(["id_1", "id_2", "id_3"])
assert len(results) == 3---
Markers and Categories
# Lightweight unit test — no marker needed, runs in CI by default
def test_calculate_total_sums_all_amounts(self) -> None: ...
# Heavy test requiring external service
@pytest.mark.heavy
class TestNeo4jGraphExtraction:
"""Integration tests requiring a running Neo4j instance."""
@pytest.mark.skipif(
not os.environ.get("NEO4J_URI"),
reason="NEO4J_URI environment variable not set",
)
def test_extract_graph_returns_populated_universe(self, neo4j_facade) -> None: ...
# Integration test
@pytest.mark.integration
@pytest.mark.heavy
class TestCsvPipelineIntegration:
"""End-to-end pipeline test using real file I/O."""
...pyproject.toml configuration
[tool.pytest.ini_options]
addopts = "--import-mode=importlib -m 'not heavy'"
markers = [
"heavy: requires external services (deselect with -m 'not heavy')",
"integration: component interaction tests",
]PYTHONPATH requirement
When using a tests/fixtures/ directory with wildcard imports, the tests folder must be on the path:
# Set before running tests
export PYTHONPATH="${PYTHONPATH}:$(pwd)/tests"
# Or inline
PYTHONPATH=tests pytest tests/unit_tests---
Test Output Conventions (bclearer / Python repos)
Tests that produce data for inspection or capture logging output MUST write to structured, timestamped folders under tests/data/. This keeps outputs isolated per run, traceable by test name, and out of source control (add to .gitignore).
Folder layout
tests/data/
├── output/
│ └── <sanitized_test_name>/
│ └── <YYYY_MM_DD_HH_MM_SS>/ ← data files for inspection (JSON, CSV, etc.)
└── logs/
└── <sanitized_test_name>/
└── <YYYY_MM_DD_HH_MM_SS>/
└── log_file<YYYY_MM_DD_HH_MM_SS>.txt ← logging decorator outputLog output (from the bclearer orchestration logging decorator) goes to logs/. Data output for inspection (JSON, CSV, graphs, etc.) goes to output/. These are always separate — never mix logs and data in the same folder.
Timestamp format
Use now_time_as_string_for_files() from bclearer orchestration:
from bclearer_orchestration_services.datetime_service.time_helpers.time_getter import (
now_time_as_string_for_files,
)
# Returns: "YYYY_MM_DD_HH_MM_SS"If the bclearer orchestration service is not available, fall back to:
from datetime import datetime
timestamp = datetime.now().strftime("%Y_%m_%d_%H_%M_%S")Test name sanitisation
Replace all non-alphanumeric characters (except ., _, -) with underscores. This makes the folder name safe for all operating systems:
import re
def _sanitize_test_name(test_name: str) -> str:
sanitized = re.sub(r"[^A-Za-z0-9._-]+", "_", test_name).strip("_")
return sanitized or "run"Standard fixtures
Define these in tests/fixtures/ and import in conftest.py. The run_stamp and test_run_name fixtures feed into both run_output_folder and run_log_folder so both folders always share the same timestamp.
import re
import pytest
from pathlib import Path
from bclearer_orchestration_services.datetime_service.time_helpers.time_getter import (
now_time_as_string_for_files,
)
from bclearer_orchestration_services.reporting_service.reporters.log_file import LogFiles
_OUTPUT_ROOT = Path(__file__).resolve().parents[1] / "data" / "output"
_LOG_ROOT = Path(__file__).resolve().parents[1] / "data" / "logs"
def _sanitize_test_name(test_name: str) -> str:
sanitized = re.sub(r"[^A-Za-z0-9._-]+", "_", test_name).strip("_")
return sanitized or "run"
@pytest.fixture(scope="function")
def run_stamp() -> str:
"""Timestamp for the current test run: YYYY_MM_DD_HH_MM_SS."""
return now_time_as_string_for_files()
@pytest.fixture(scope="function")
def test_run_name(request: pytest.FixtureRequest) -> str:
"""Sanitized test name, safe for use as a directory name."""
return _sanitize_test_name(request.node.name)
@pytest.fixture(scope="function")
def run_output_folder(
test_run_name: str,
run_stamp: str,
) -> Path:
"""Create and return tests/data/output/<test_name>/<timestamp>/."""
folder = _OUTPUT_ROOT / test_run_name / run_stamp
folder.mkdir(parents=True, exist_ok=True)
return folder
@pytest.fixture(scope="function")
def run_log_folder(
test_run_name: str,
run_stamp: str,
) -> Iterator[Path]:
"""Create tests/data/logs/<test_name>/<timestamp>/ and open the log file."""
folder = _LOG_ROOT / test_run_name / run_stamp
folder.mkdir(parents=True, exist_ok=True)
LogFiles.open_log_file(folder_path=str(folder), now_time=run_stamp)
try:
yield folder
finally:
LogFiles.close_log_file()Using the fixtures in tests
Apply run_log_folder at module level so every test in the file gets logging:
import pytest
from pathlib import Path
pytestmark = pytest.mark.usefixtures("run_log_folder")
class TestFileSystemSnapshotWorkflow:
"""Integration tests for the file system snapshot service."""
def test_run_basic_snapshot(
self,
run_output_folder: Path,
snapshot_configuration: FileSystemSnapshotConfigurations,
) -> None:
"""Test that a basic snapshot produces a populated universe."""
# Act
universe = FileSystemSnapshotServiceFacade.run_file_system_snapshot(
configurations=snapshot_configuration,
)
# Assert
assert universe is not None
# Write data output for inspection (separate from logs)
(run_output_folder / "universe_summary.json").write_text(
json.dumps(universe.to_summary_dict(), indent=2),
)Parallel execution (pytest-xdist)
When running with -n auto, isolate output by worker to avoid folder collisions:
import os
worker_id = os.environ.get("PYTEST_XDIST_WORKER")
if worker_id:
folder = _OUTPUT_ROOT / worker_id / test_run_name / run_stamp.gitignore entries
Add these to .gitignore — test outputs are never committed:
tests/data/output/
tests/data/logs/---
bclearer / BIE / BORO Patterns
Identity vector tests
def test_empty_vector_produces_zero_bie_id(self) -> None:
"""Test that an empty identity vector yields a zero-dimensional BIE ID."""
vector = CommonIdentityVector()
bie_id = BieIdCreationFacade.create_bie_id_from_identity_vector(
identity_vector=vector,
)
assert bie_id.bie_vector_structure_type == BieVectorStructureTypes.ZERO_DIMENSIONAL
def test_populated_vector_produces_multi_dimensional_bie_id(self) -> None:
"""Test that a populated vector produces a multi-dimensional BIE ID."""
vector = FileSystemObjectIdentityVector(
absolute_path=Path("/home/user/file.txt"),
)
bie_id = BieIdCreationFacade.create_bie_id_from_identity_vector(
identity_vector=vector,
)
assert bie_id.bie_vector_structure_type == BieVectorStructureTypes.MULTI_DIMENSIONAL_ORDER_SENSITIVERegistry mock pattern (BORO)
def test_register_enum_calls_registry_once(self) -> None:
"""Test that enum registration invokes the registry exactly once."""
mock_registry = MagicMock()
register_bie_enums_to_registry_base(
bie_enum_leaf_type=BieEnums,
bie_registry=mock_registry,
)
mock_registry.register_bie_id_if_required.assert_called_once()
call_kwargs = mock_registry.register_bie_id_if_required.call_args
assert call_kwargs.kwargs["bie_item_id"] == BieEnums.enum_bie_identityInternal test helper classes
Use an underscore prefix for test-internal subclasses:
class _TestRegistry(BieIdRegistries):
"""Registry subclass used only within this test module."""
...Fixture file in bclearer layout
# tests/fixtures/file_system_snapshot_service/configurations.py
@pytest.fixture
def snapshot_configuration(
test_input_folder: Path,
test_output_folder: Path,
) -> FileSystemSnapshotConfigurations:
"""Fully configured snapshot configuration for FSS tests."""
return FileSystemSnapshotConfigurations(
input_folder=Folders(absolute_path=test_input_folder),
output_folder=Folders(absolute_path=test_output_folder),
)Clean Code Tests — Rust
Language-specific testing patterns for Rust. Read alongside references/testing-philosophy.md and references/testing-standards.md.
---
Framework and Tooling
| Tool | Purpose |
|---|---|
#[test] (std) | Built-in unit test attribute — no external crate needed |
#[tokio::test] | Async test runner (tokio runtime) |
mockall | Mock trait implementations — #[automock] |
rstest | Parametrised tests and fixture injection |
tempfile | Temporary directories and files in tests |
assert_matches! | Pattern-matching assertions (stable since 1.73) |
cargo test | Test runner |
---
File and Module Structure
// src/module/component.rs
pub struct TransactionLoader {
base_path: PathBuf,
}
impl TransactionLoader {
pub fn load(&self, filename: &str) -> Result<Vec<Transaction>, LoadError> {
// ...
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn make_loader(dir: &TempDir) -> TransactionLoader {
TransactionLoader { base_path: dir.path().to_path_buf() }
}
fn write_csv(dir: &TempDir, filename: &str, content: &str) {
std::fs::write(dir.path().join(filename), content).unwrap();
}
#[test]
fn load_returns_records_when_csv_is_valid() {
// Arrange
let dir = TempDir::new().unwrap();
write_csv(&dir, "data.csv", "id,amount\n1,100\n2,200");
let loader = make_loader(&dir);
// Act
let result = loader.load("data.csv");
// Assert
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
let records = result.unwrap();
assert_eq!(records.len(), 2);
}
#[test]
fn load_returns_error_when_file_is_missing() {
// Arrange
let dir = TempDir::new().unwrap();
let loader = make_loader(&dir);
// Act
let result = loader.load("missing.csv");
// Assert
assert!(result.is_err());
assert_matches!(result.unwrap_err(), LoadError::FileNotFound { .. });
}
}Rules:
- Tests live in a
#[cfg(test)] mod testsblock in the same file as the code use super::*brings all items from the parent module into scope- Integration tests live in
tests/at the crate root (separate from source) - Helper functions (
make_loader,write_csv) are plain functions, not fixtures
---
Naming
| Unit | Convention | Example |
|---|---|---|
| Test function | snake_case — reads as a sentence | load_returns_records_when_csv_is_valid |
| Test module | tests (standard) or descriptive submodule | mod load_tests |
| Test helper | make_<thing> or build_<thing> | make_loader, build_valid_record |
Do not prefix with test_ — the #[test] attribute makes the intent clear.
---
Helper Functions (Builders)
Rust has no fixture injection built in. Use plain helper functions instead:
fn make_valid_record() -> Transaction {
Transaction {
id: "tx-001".to_string(),
amount: 100.0,
currency: "GBP".to_string(),
timestamp: Utc::now(),
}
}
fn make_loader_with_data(records: &[(&str, &str)]) -> (TempDir, TransactionLoader) {
let dir = TempDir::new().unwrap();
let mut csv = "id,amount\n".to_string();
for (id, amount) in records {
csv.push_str(&format!("{},{}\n", id, amount));
}
std::fs::write(dir.path().join("data.csv"), &csv).unwrap();
let loader = TransactionLoader { base_path: dir.path().to_path_buf() };
(dir, loader) // return TempDir to keep it alive
}---
Error Path Testing
#[test]
fn load_returns_file_not_found_when_path_is_missing() {
let dir = TempDir::new().unwrap();
let loader = make_loader(&dir);
let result = loader.load("nonexistent.csv");
assert!(result.is_err());
assert_matches!(
result.unwrap_err(),
LoadError::FileNotFound { ref path } if path.ends_with("nonexistent.csv")
);
}
#[test]
fn parse_returns_parse_error_on_malformed_csv() {
let result = parse_csv("id,amount\nnot-a-number,abc");
assert_matches!(result.unwrap_err(), ParseError::InvalidField { line: 1, .. });
}---
Parametrised Tests (rstest)
use rstest::rstest;
#[rstest]
#[case("hello", "HELLO")]
#[case("world", "WORLD")]
#[case("", "" )]
fn transform_uppercases_input(#[case] input: &str, #[case] expected: &str) {
let result = transform(input);
assert_eq!(result, expected);
}
#[rstest]
#[case("../etc/passwd", "path traversal")]
#[case("/absolute", "absolute path" )]
fn load_rejects_unsafe_paths(#[case] path: &str, #[case] expected_fragment: &str) {
let result = load(path);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(
error_message.contains(expected_fragment),
"Expected error to contain '{}', got: '{}'",
expected_fragment,
error_message,
);
}---
Async Tests
#[tokio::test]
async fn fetch_record_returns_result_for_valid_id() {
// Arrange
let mock_client = MockApiClient::new();
mock_client.expect_get()
.with(eq("record-1"))
.returning(|_| Ok(Transaction { id: "record-1".into(), amount: 100.0 }));
let service = RecordService::new(Arc::new(mock_client));
// Act
let result = service.fetch("record-1").await;
// Assert
assert!(result.is_ok());
assert_eq!(result.unwrap().amount, 100.0);
}
#[tokio::test]
async fn fetch_record_propagates_network_error() {
let mock_client = MockApiClient::new();
mock_client.expect_get()
.returning(|_| Err(ApiError::NetworkTimeout));
let service = RecordService::new(Arc::new(mock_client));
let result = service.fetch("any-id").await;
assert_matches!(result.unwrap_err(), ServiceError::Api(ApiError::NetworkTimeout));
}---
Mocking with mockall
use mockall::{automock, predicate::*};
#[automock]
trait RecordRepository {
fn find(&self, id: &str) -> Result<Transaction, RepoError>;
fn save(&mut self, record: &Transaction) -> Result<(), RepoError>;
}
#[test]
fn process_saves_transformed_record() {
// Arrange
let mut mock_repo = MockRecordRepository::new();
mock_repo.expect_find()
.with(eq("tx-1"))
.times(1)
.returning(|_| Ok(Transaction { id: "tx-1".into(), amount: 50.0 }));
mock_repo.expect_save()
.times(1)
.returning(|_| Ok(()));
let processor = TransactionProcessor::new(mock_repo);
// Act
let result = processor.process("tx-1");
// Assert
assert!(result.is_ok());
}---
Integration Tests (crate-level tests/)
// tests/integration/csv_pipeline.rs
use std::path::PathBuf;
use tempfile::TempDir;
use my_crate::pipeline::Pipeline;
#[test]
fn run_pipeline_produces_expected_output_for_valid_csv() {
// Arrange
let dir = TempDir::new().unwrap();
let input = dir.path().join("input.csv");
let output = dir.path().join("output.json");
std::fs::write(&input, "id,amount\n1,100\n2,200").unwrap();
// Act
Pipeline::run(&input, &output).unwrap();
// Assert
let result = std::fs::read_to_string(&output).unwrap();
assert!(result.contains(r#""amount":100"#));
assert!(result.contains(r#""amount":200"#));
}---
Assertion Style
// Equality
assert_eq!(result.len(), 3, "Expected 3 records, got {}", result.len());
// Inequality
assert_ne!(first_id, second_id);
// Boolean
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
assert!(result.is_err());
// Pattern matching (preferred for enums)
assert_matches!(result.unwrap_err(), LoadError::FileNotFound { .. });
// Struct fields
let record = result.unwrap();
assert_eq!(record.id, "tx-001");
assert!((record.amount - 100.0).abs() < f64::EPSILON); // float comparison---
Anti-Patterns (Rust Specific)
| Anti-pattern | Why |
|---|---|
.unwrap() in assertion position without message | Panic message is useless — add `unwrap_or_else(\ |
.unwrap() in production code being tested via panic | Tests should verify Result / Option outcomes, not rely on panics |
Ignoring TempDir return value | Drops immediately → directory deleted before test runs; bind to a variable |
#[allow(unused)] on test fields | Suppresses legitimate warnings; prefer _ prefix or remove the field |
Testing to_string() output for error matching | Fragile — use assert_matches! on the enum variant instead |
Testing Philosophy
Core principles that apply to all languages and test types.
---
F.I.R.S.T.
Every test must satisfy all five properties:
| Principle | Rule |
|---|---|
| Fast | Unit tests run in < 1 second each; full unit suite in < 5 minutes |
| Independent | Tests do not depend on each other; pass in any order, in any subset |
| Repeatable | Same result in every environment — local, CI, offline |
| Self-Validating | Boolean pass/fail; no manual log inspection required |
| Timely | Written alongside production code, not as an afterthought |
---
Test Code Is Production Code
Test code requires the same care, design, and maintenance as production code.
- Dirty tests → harder to change production code → tests get abandoned → code rots
- Clean tests → confidence to refactor → production code stays healthy
The dirtier the tests, the dirtier the code becomes.
---
The Three Laws of TDD
1. Do not write production code until you have a failing unit test 2. Do not write more of a unit test than is sufficient to fail (not compiling is failing) 3. Do not write more production code than is sufficient to pass the failing test
These laws produce a tight red/green/refactor cycle — roughly thirty seconds long.
---
One Concept per Test
The rule is not strictly "one assert per test" — it is one concept per test.
A test that checks a single behaviour may need multiple assertions to express it fully. That is acceptable. What is not acceptable is a test that exercises two unrelated scenarios in the same function — that makes failures ambiguous and names impossible to write.
# Bad — two unrelated concepts
test_add_months_handles_month_boundary_and_leap_year
# Good — one concept each
test_add_months_wraps_to_next_month_when_source_is_31st
test_add_months_clips_to_february_28_in_non_leap_year---
Readability Above All
What makes a clean test? Readability, readability, readability.
Tests are documentation. A reader should be able to understand what the production code does — and what it guarantees — by reading the test names and bodies alone, without reading the implementation.
Build-Operate-Check
Tests follow a simple three-phase pattern:
1. Build — create test data and set up the scenario 2. Operate — execute the thing under test 3. Check — verify the results
This maps directly to Arrange / Act / Assert. Separate each phase with a blank line.
Domain-Specific Test Language
Build helpers that make the scenario obvious:
# Instead of
user = User(name="Alice", role="admin", active=True, created=datetime.now())
service = AuthService(db=FakeDb([user]))
result = service.check_permission(user.id, "write_report")
assert result is True
# Prefer
user = make_active_admin(name="Alice")
assert can_write_reports(user)Helpers like make_active_admin() and can_write_reports() read like a specification, not like infrastructure wiring.
---
Tests Enable Change
Tests are what keep production code flexible, maintainable, and reusable.
- Without tests, every change is a potential bug
- With tests, you can change code without fear
- If you let the tests rot, the code will rot too
Testing Standards
Quality rules, structure, and checklist for all tests. Read alongside testing-philosophy.md and the relevant languages/[language].md.
---
1. Coverage Requirements
| Metric | Threshold |
|---|---|
| Overall coverage | 80% minimum |
| Critical path (core business logic) | 95% minimum |
| Branch coverage | 75% minimum |
| New code | 90% minimum |
Test distribution target: 70% unit / 20% integration / 10% end-to-end.
Each module MUST cover:
- Happy path scenarios
- Error conditions and all exception types raised
- Edge cases and boundary conditions
- Input validation
- Resource cleanup
---
2. Directory Structure
tests/
├── unit_tests/ # Fast, no external deps — always run in CI
│ └── <module>/
├── integration_tests/ # Require external services — marked heavy/slow
├── e2e_tests/
├── fixtures/ # Shared test fixtures (imported by conftest)
│ └── <service>/
├── data/
│ ├── input/ # Static input files — small and representative
│ └── output/ # Expected outputs for comparison
└── conftest.[ext] # Root fixture configurationFile naming mirrors source:
- Source:
src/module/component.[ext] - Test:
tests/unit_tests/module/test_component.[ext]
---
3. Naming Conventions
Class: Test<ComponentName>
Method: test_<action>_<condition>_<expected_result>Good names:
test_load_transactions_returns_list_when_valid_csv
test_load_transactions_raises_file_not_found_when_path_missing
test_load_transactions_returns_empty_list_when_csv_is_empty
test_connect_invalid_credentials_raises_auth_errorBad names: test_1, test_stuff, test_error, test_it_works, testLoad
---
4. Arrange / Act / Assert (AAA)
Every test body follows AAA, with each phase separated by a blank line. Every test has a docstring or comment describing what it verifies.
[Arrange] — create inputs, configure mocks, set up scenario
[Act] — call the thing under test, capture the result
[Assert] — verify the outcome matches expectationsRules:
- One concept per test — test one behaviour, not one line
- Multiple asserts are allowed when they all verify the same single behaviour
- No logic in tests — no loops, conditionals, or try/catch in test bodies
- Prefer named helpers over inline magic values
---
5. Assertions
# REQUIRED: specific, with failure context
assert result.status == "success", f"Expected success, got {result.status}"
assert len(items) == 3, f"Expected 3 items, got {len(items)}"
# FORBIDDEN: vague or meaningless
assert result # any truthy value passes — detects nothing
assert True # always passes — meaningless---
6. Test Independence
- Each test MUST clean up its own resources — use setup/teardown or fixture yield
- No shared mutable state between tests
- Tests MUST pass in any order, in any subset
- Database tests MUST use transactions or per-test cleanup
---
7. Performance
| Category | Limit per test |
|---|---|
| Unit test | < 1 second |
| Integration test | < 10 seconds |
| Full unit suite | < 5 minutes |
Tests that exceed these limits MUST be marked as heavy/slow and excluded from CI default runs.
---
8. Fixture Design
| Scope | Use for |
|---|---|
| Function (default) | Temporary folders, per-test objects, mutable state |
| Class | Shared setup across methods in one test class |
| Module | Read-only configuration shared across a file |
| Session | Expensive resources — DB connections, large file paths |
Rules:
- Every fixture has a docstring describing what it provides and its scope
- Common fixtures go in a shared conftest or fixtures directory
- Fixtures clean up after themselves (yield + teardown, not just return)
- Required categories: configuration, data/files, mock, database (integration only)
---
9. Mocking
| Rule | Detail |
|---|---|
| External services MUST be mocked in unit tests | DB, API, filesystem, clock |
| Never mock the thing under test | Mock dependencies, not the subject |
| Mocks MUST match the real interface | Same method names, same argument shapes |
| Mock failures MUST be tested | Not just the happy path |
| Verify call interactions | Assert the mock was called with the expected arguments |
---
10. Error Path Testing
Every exception path in production code MUST have at least one test. Verify both the exception type and the message content.
# Pattern — verify type AND message
raises <ExceptionType> matching "<message fragment>"
when <condition>Also test:
- Error recovery mechanisms
- Cleanup after errors (resources released even on failure)
- Retry logic where present
---
11. Parametrised Tests
Use parametrisation for multiple input scenarios of the same behaviour. Each combination gets its own named test case. Do not use a loop.
Scenarios:
("hello", "HELLO")
("world", "WORLD")
("", "" )
→ generates three independently named, independently runnable tests---
12. Async Tests
- Use the project's standard async test marker (language-specific — see language reference)
- Test timeout scenarios — do not assume async calls complete instantly
- Test cancellation handling where the production code supports it
- Do not use sleep/wait without a timeout guard
---
13. Test Markers and Categories
| Marker | Meaning |
|---|---|
| (none) | Lightweight unit test — no external deps, fast, runs in CI |
heavy / slow | Requires external service or large download — local/staging only |
integration | Component interaction test |
async | Asynchronous test requiring special runner |
Integration tests additionally require:
- Test-specific configuration (not production config)
- Isolated test data (no cross-test bleed)
- Proper cleanup after each test
---
14. Anti-Patterns
| Anti-pattern | Why it's wrong |
|---|---|
assert True or no assertion | Test always passes — detects nothing |
assert result without specifics | Any truthy value passes |
| Testing the mock, not production code | Verifies the mock, not behaviour |
| Logic in tests (loops, conditionals) | Obscures intent; introduces test bugs |
| Hardcoded absolute paths | Breaks on other machines and CI |
sleep/wait without timeout | Flaky; can hang CI indefinitely |
| Tests requiring specific execution order | Hidden coupling between tests |
| Multiple unrelated concepts in one test | Impossible to name; hard to diagnose |
| Commented-out tests | Delete them; re-add with a ticket if needed |
| Modifying production data in tests | Tests must be side-effect free |
| Mocking the thing under test | You are testing the mock, not the code |
---
15. Compliance Checklist
All tests
- [ ] Name follows
test_<action>_<condition>_<result>pattern - [ ] Has docstring or description explaining what is being verified
- [ ] AAA structure with blank-line separation between phases
- [ ] Assertions are specific and include failure context
- [ ] Cleans up all resources
- [ ] Independent — passes in any order, in any subset
- [ ] Completes within performance limit
Unit tests (additional)
- [ ] All external dependencies mocked
- [ ] The thing under test is NOT mocked
- [ ] Tests one concept per method
- [ ] No logic (loops, conditionals) in test body
Integration tests (additional)
- [ ] Marked as integration / heavy
- [ ] Uses test-specific configuration, not production config
- [ ] Verifies component interactions across real boundaries
- [ ] Handles cleanup properly after each test
Async tests (additional)
- [ ] Uses the correct async test marker for the language
- [ ] All async calls properly awaited
- [ ] Timeout scenarios covered
- [ ] Cancellation handling tested where applicable