A living document for testing standards, patterns, and coverage tracking.
Last updated: 2026-01-24
Tests exist to catch bugs before users do and to document expected behavior. We prioritize:
- Testing critical paths over hitting coverage numbers
- Readable tests that serve as documentation
- Fast, reliable tests that don't depend on external services
- Practical coverage - test logic that can break, skip trivial getters/setters
| Code Type | Target Coverage | Notes |
|---|---|---|
| Repositories | 80%+ | Data layer is critical |
| API Clients | 80%+ | External integrations need thorough testing |
| Domain Logic | 80%+ | Business rules must be verified |
| Controllers | 60%+ | Focus on happy path + error handling |
| Commands | 50%+ | Integration-heavy, harder to unit test |
| Config/Bootstrap | Not required | Tested implicitly by other tests |
Every function with logic should have tests for:
- Happy path - Normal successful operation
- Null/empty inputs - null, empty string, empty array, zero
- Edge cases - Boundary values, unusual but valid inputs
- Error conditions - Invalid inputs, failure scenarios
For every happy path test, write 2-3 negative tests. AI-generated tests (and human instinct) skew toward optimistic "it works" cases. Explicitly demand failure scenarios.
Example ratio for a login function:
- 2 happy path tests (valid credentials, remember me option)
- 5 negative tests (empty input, malformed email, wrong password, null value, account locked)
Never make real network requests in tests. All external services must be mocked:
- Discogs API
- Anthropic API
- Apple Music API
- Any HTTP calls
Every test follows this structure:
public function testUserCanSaveRating(): void
{
// Arrange - Set up test data and dependencies
$repository = new SqliteCollectionRepository($this->pdo);
$releaseId = 12345;
$username = 'testuser';
$rating = 4;
// Act - Perform the action being tested
$repository->updateRating($releaseId, $username, $rating);
// Assert - Verify the expected outcome
$item = $repository->findCollectionItem($releaseId, $username);
$this->assertEquals(4, $item['rating']);
}Use Mockery to mock Guzzle clients for API tests:
use Mockery;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
public function testHandlesDiscogsRateLimitResponse(): void
{
// Arrange
$mockClient = Mockery::mock(Client::class);
$mockClient->shouldReceive('request')
->with('GET', 'releases/12345', Mockery::any())
->andReturn(new Response(429, ['Retry-After' => '60'], ''));
$discogsClient = new DiscogsHttpClient(/* inject mock */);
// Act
$result = $discogsClient->getRelease(12345);
// Assert
$this->assertNull($result);
}/**
* @dataProvider emptyInputProvider
*/
public function testHandlesEmptySearchQuery(mixed $input): void
{
$parser = new QueryParser();
$result = $parser->parse($input);
$this->assertEquals('', $result['match']);
$this->assertEmpty($result['filters']);
}
public static function emptyInputProvider(): array
{
return [
'null' => [null],
'empty string' => [''],
'whitespace only' => [' '],
];
}Happy path tests verify "it works." Negative tests verify "it fails gracefully." Both are essential.
Categories of negative tests to include:
| Category | What to Test | Example |
|---|---|---|
| Empty input | Empty string, empty array | parse('') returns empty result |
| Null input | Explicit null values | save(null) throws or returns false |
| Malformed input | Invalid format | validateEmail('not-an-email') fails |
| Boundary values | Just beyond valid range | Rating of 6 when max is 5 |
| Massive input | Extremely large data | 1MB string, 10000-item array |
| Network failures | Timeouts, connection errors | API timeout returns null, not exception |
| Missing dependencies | File not found, DB down | Graceful error message |
| Concurrent access | Race conditions | Two saves to same record |
Example: Testing a search function
// Happy path (2 tests)
public function testSearchFindsMatchingReleases(): void { /* ... */ }
public function testSearchWithFiltersNarrowsResults(): void { /* ... */ }
// Negative tests (5 tests)
public function testSearchWithEmptyQueryReturnsAll(): void { /* ... */ }
public function testSearchWithNullQueryReturnsAll(): void { /* ... */ }
public function testSearchWithOnlyWhitespaceReturnsAll(): void { /* ... */ }
public function testSearchWithMassiveQueryDoesNotCrash(): void
{
$hugeQuery = str_repeat('a', 100000);
$parser = new QueryParser();
$result = $parser->parse($hugeQuery); // Should not throw
$this->assertIsArray($result);
}
public function testSearchWithSqlInjectionAttemptIsSafe(): void { /* ... */ }Use transactions to isolate tests and roll back after each:
protected function setUp(): void
{
parent::setUp();
$this->pdo = new PDO('sqlite::memory:');
// Run migrations
(new MigrationRunner($this->pdo))->run();
$this->pdo->beginTransaction();
}
protected function tearDown(): void
{
$this->pdo->rollBack();
parent::tearDown();
}tests/
├── Unit/ # Tests for isolated classes, no I/O
│ ├── ValidatorTest.php
│ ├── QueryParserTest.php
│ └── ConfigTest.php
├── Integration/ # Tests involving database or multiple classes
│ ├── CollectionRepositoryTest.php
│ ├── ReleaseRepositoryTest.php
│ └── HealthCheckMiddlewareTest.php
└── Feature/ # End-to-end tests (future)
└── ...
Unit tests: Fast, no database, no file system, no network. Mock all dependencies.
Integration tests: Can use in-memory SQLite, test class interactions.
Feature tests: Full request/response cycle (not yet implemented).
Note: the
Coveragepercentages in the tables below are hand estimates — they predate any coverage driver being installed and were never measured. For an actual signal of test quality, see the measured Mutation Scores in the Mutation Testing section above. Treat these numbers as rough intent, not data.
| File | Test File | Coverage | Notes |
|---|---|---|---|
Http/Validation/Validator.php |
Unit/ValidatorTest.php |
~80% | Core validation rules covered |
Domain/Search/QueryParser.php |
Unit/QueryParserTest.php |
~90% | All filters, edge cases, data providers |
Http/DiscogsCollectionWriter.php |
Unit/DiscogsCollectionWriterTest.php |
~95% | Rating, fields, error handling |
Http/DiscogsWantlistWriter.php |
Unit/DiscogsWantlistWriterTest.php |
~95% | Add/remove wantlist, collection |
Infrastructure/AnthropicClient.php |
Unit/AnthropicClientTest.php |
~90% | Success, errors, JSON parsing |
Infrastructure/AppleMusicClient.php |
Unit/AppleMusicClientTest.php |
~90% | UPC search, text search, matching |
Infrastructure/Persistence/SqliteReleaseRepository.php |
Integration/ReleaseRepositoryTest.php |
~85% | CRUD, images, recommendations |
Sync/CollectionImporter.php |
Integration/CollectionImporterTest.php |
~85% | Pagination, updates, error handling |
Sync/WantlistImporter.php |
Integration/WantlistImporterTest.php |
~85% | Pagination, updates, error handling |
Sync/ReleaseEnricher.php |
Integration/ReleaseEnricherTest.php |
~90% | API calls, barcode/tracklist, error handling |
Http/DiscogsHttpClient.php |
Unit/DiscogsHttpClientTest.php |
~95% | Config, headers, handler stack |
Http/Middleware/RateLimiterMiddleware.php |
Unit/RateLimiterMiddlewareTest.php |
~85% | Header recording, throttling, 429 handling |
Http/Middleware/RetryMiddleware.php |
Unit/RetryMiddlewareTest.php |
~90% | Retry logic, backoff, status codes |
Infrastructure/KvStore.php |
Integration/KvStoreTest.php |
~95% | get/set/incr, edge cases |
Images/ImageCache.php |
Integration/ImageCacheTest.php |
~90% | Fetch, quota, rate limiting |
Http/Controllers/CollectionController.php |
Integration/CollectionControllerTest.php |
~75% | index, stats, random, about |
Http/Controllers/SearchController.php |
Integration/SearchControllerTest.php |
~90% | save, delete, CSRF, validation |
Http/Controllers/ReleaseController.php |
Integration/ReleaseControllerTest.php |
~80% | show, save, add, CSRF, validation |
Http/Controllers/AppleMusicController.php |
Integration/AppleMusicControllerTest.php |
~90% | Barcode/text search, caching, error handling |
Http/Controllers/RecommendationController.php |
Integration/RecommendationControllerTest.php |
~85% | Caching, prompt building, collection summary |
Infrastructure/Config.php |
Unit/ConfigTest.php |
~95% | env(), paths, credentials, validation |
Infrastructure/Storage.php |
Integration/StorageTest.php |
~90% | PDO setup, WAL mode, directory creation |
Presentation/Twig/DiscogsFilters.php |
Unit/DiscogsFiltersTest.php |
~95% | Numeric suffix stripping, edge cases |
Infrastructure/Persistence/SqliteCollectionRepository.php |
Integration/CollectionRepositoryTest.php |
~90% | All methods: searches, collection, wantlist, stats, transactions |
Domain/Valuation/ConditionGrades.php |
Unit/ConditionGradesTest.php |
~90% | normalize, fromDiscogsFields, CANONICAL list |
Domain/Valuation/SnapshotChart.php |
Unit/ValuationChartTest.php |
~90% | SVG polyline rendering, empty/single-point edge cases |
Domain/Valuation/InsuranceManifest.php |
Unit/InsuranceManifestTest.php |
~90% | CSV rows, totals footer, coverage string, quoting |
Infrastructure/DiscogsPricingClient.php |
Integration/DiscogsPricingClientTest.php |
~85% | suggestion/lowest_listed paths, seller-settings fallback, rate-limit reuse |
Infrastructure/Persistence/SqliteValuationRepository.php |
Integration/SqliteValuationRepositoryTest.php |
~85% | save/load snapshots, schema migration, reset |
Sync/CollectionValuer.php |
Integration/CollectionValuerTest.php |
~80% | stale-days skip, --force, --id, scope, source labelling |
| File | Test File | Coverage | Missing |
|---|---|---|---|
Http/Middleware/HealthCheckMiddleware.php |
Integration/HealthCheckMiddlewareTest.php |
~60% | Edge cases |
All high-priority items are now tested.
All medium-priority items are now tested.
| File | Why | Suggested Test Type |
|---|---|---|
Http/Controllers/ToolsController.php |
Admin UI | Integration |
Infrastructure/MigrationRunner.php |
Tested implicitly | - |
Infrastructure/ContainerFactory.php |
Bootstrap code | - |
Console/*Command.php |
CLI wrappers, integration-heavy | Feature |
# Run all tests
./vendor/bin/phpunit tests
# Run with coverage report (requires Xdebug or PCOV)
./vendor/bin/phpunit tests --coverage-html coverage/
# Run specific test file
./vendor/bin/phpunit tests/Unit/ValidatorTest.php
# Run specific test method
./vendor/bin/phpunit --filter testRequiredRule
# Run only unit tests
./vendor/bin/phpunit tests/Unit
# Run only integration tests
./vendor/bin/phpunit tests/IntegrationMutation testing answers: "Are my tests actually catching bugs, or just running code?"
- A tool (Infection for PHP) modifies your code ("mutates" it)
- Examples: changes
>to>=, removes a line, flipstruetofalse - Your tests run against each mutation
- If tests still pass with broken code, you have a gap
Infection is installed (infection/infection in require-dev), configured via
infection.json5. It needs a coverage driver, which Herd's PHP doesn't ship and
which it won't load via PHP_INI_SCAN_DIR. The bin/mutation wrapper handles
this: it generates a coverage report using Herd's bundled Xdebug (loaded with
-d), then runs Infection against that report.
# All of src/ (slow - the coverage run executes the full suite once)
bin/mutation
# Specific file(s) - fast, reuses one coverage report (Infection --filter)
bin/mutation QueryParser.php
bin/mutation DiscogsCollectionWriter.php,DiscogsWantlistWriter.php
# Gate for CI: non-zero exit if MSI drops below the threshold
MIN_MSI=70 bin/mutation| Term | Meaning |
|---|---|
| Killed | Mutation was caught by a test (good) |
| Escaped | A test ran the mutated code but still passed — a real test gap |
| Timed Out | Mutation caused an infinite loop/hang a test triggered — DETECTED (counts as killed) |
| Not Covered | No test exercises the mutated line at all |
| MSI | Mutation Score Indicator — % of mutants detected (killed + timed out + errored) |
Only "Escaped" mutants are real gaps. When reading the per-line logs, the
Timed Out section is NOT a list of survivors — filter to the Escaped mutants:
section. Infection's summary MSI is the authoritative number.
Target: 70%+ MSI for critical code.
Some mutants are equivalent (the mutated code behaves identically — e.g.
(int)'1980' vs '1980' in a numeric comparison, or a value that is cast but
never used) and cannot be killed. Others are structural: a class that
hard-constructs its HTTP client (tested via reflection) or calls usleep()
directly can't have that config/timing verified without a source change
(dependency injection or a clock/sleeper). Chasing these means brittle or
slow/flaky tests — leave them and note why, rather than inflate the score.
Real MSI, measured with bin/mutation (not the coverage estimates below, which
predate any coverage driver being installed):
| File | MSI | Notes |
|---|---|---|
Http/DiscogsWantlistWriter.php |
100% | |
Http/Validation/Validator.php |
97% | 1 equivalent mutant |
Http/DiscogsCollectionWriter.php |
95% | |
Sync/WantlistImporter.php |
88% | |
Sync/CollectionImporter.php |
87% | rest are DB-round-trip (int) casts |
Infrastructure/AppleMusicClient.php |
87% | client injected; rest are error_log text |
Domain/Search/QueryParser.php |
86% | |
Infrastructure/AnthropicClient.php |
77% | client injected; rest are error_log text + equivalents |
Http/Controllers/CollectionController.php |
71% | live-Discogs paths covered via an injected client factory |
Http/Middleware/RetryMiddleware.php |
66% | rest are randomness-bound backoff math |
Http/Controllers/ReleaseController.php |
64% | rest are cosmetic (template payloads, redirect URLs) |
Http/Middleware/RateLimiterMiddleware.php |
62% | sleeper injected; rest are randomness-bound backoff math |
Images/ImageCache.php |
54% | structural: still needs DI + a clock for the rps throttle |
Already strong without extra work: Http/Controllers/SearchController.php (100%),
Infrastructure/Persistence/SqliteReleaseRepository.php (100%),
Infrastructure/KvStore.php (100%), SqliteCollectionRepository.php (93%),
Http/Controllers/AppleMusicController.php (85%).
Not yet strengthened (worst-first): HealthCheckMiddleware (~57%), Storage
(~61%), RecommendationController (75%), ReleaseEnricher (79%). The remaining
controller survivors are mostly cosmetic (view-rendering) mutants.
- Not on every commit - too slow
- Before major releases - verify test quality
- Quarterly - catch test rot
- After writing new tests - verify they're meaningful
$ ./vendor/bin/infection --filter=Validator
# Output shows:
# Mutant survived: changed `$length >= $min` to `$length > $min`
# in Validator.php line 45
# This means: your test doesn't catch off-by-one errors
# Fix: Add a test for the exact boundary valueWhen a mutant survives:
- Understand what the mutation changed
- Ask: "Should my tests have caught this?"
- If yes, add a test that fails with the mutation
- If no (irrelevant mutation), add to ignore list
When adding a new feature:
- Before coding: Consider what tests you'll need
- While coding: Write tests alongside implementation
- Before committing:
- Happy path tests exist (1-2 per function)
- Negative tests exist (2-3 per function): null input, empty input, invalid input
- Edge case tests exist (boundary values, large inputs)
- All tests pass:
./vendor/bin/phpunit tests - PHPStan passes:
./vendor/bin/phpstan analyse
- Update this document: Add new files to the coverage inventory
## Feature: [Name]
### Files Added/Modified
- [ ] `src/Path/To/File.php`
### Tests Added
- [ ] `tests/Unit/FileTest.php` or `tests/Integration/FileTest.php`
### Test Cases (aim for 2-3 negative tests per happy path)
- [ ] Happy path (normal operation)
- [ ] Negative: null input
- [ ] Negative: empty input (string, array)
- [ ] Negative: invalid/malformed input
- [ ] Negative: boundary values (off-by-one)
- [ ] Negative: error conditions (network, DB)
- [ ] Edge case: large input (if applicable)
### Coverage
- Target: X%
- Actual: X% (after running coverage report)- After adding new source files
- After adding new test files
- After significant refactoring
- When coverage targets change
- Quarterly review (even if no changes)
Track files that need tests but haven't been written yet:
| File | Reason Deferred | Target Date |
|---|---|---|
| - | - | - |