This document outlines the strategy for improving test coverage across the Palace iOS codebase and establishes mandatory TDD requirements for all agents working on bugs and features.
- Current State
- Target State
- TDD Requirements for Agents
- Testing Guidelines
- Migration Phases
- Priority Classes for Coverage
- Mock Infrastructure
- CI/CD Integration
| Layer | Current Coverage | Target |
|---|---|---|
| ViewModels | ~60% | 85% |
| Business Logic | ~50% | 90% |
| Utilities | ~40% | 80% |
| Models | ~70% | 95% |
| Network Layer | ~30% | 75% |
BookButtonMapper/BookButtonStateCatalogFilter/CatalogFilterGroup/CatalogLaneModelHoldsViewModel(with DI)BookCellModelCacheFacetViewModel- Hold badge count logic
MyBooksDownloadCenter- Complex download logicTPPBookRegistry- Core state managementAccountsManager- Account switching logicTPPAnnotations- Server sync logic- PDF/EPUB reader business logic
- Audiobook playback logic
ViewModels: 85% coverage (from 60%)
Business Logic: 90% coverage (from 50%)
Utilities: 80% coverage (from 40%)
Models: 95% coverage (from 70%)
Network Layer: 75% coverage (from 30%)
- Zero flaky tests in CI
- All tests run in < 2 minutes
- No network dependencies in unit tests
- 100% of new code has corresponding tests
All agents (AI assistants) working on this codebase MUST follow TDD for:
- Bug fixes
- New features
- Refactoring existing code
1. FIRST: Write a failing test that reproduces the bug
- Include ticket number in test comment (e.g., "/// Regression test for PP-1234")
- Test MUST fail before the fix is applied
2. THEN: Implement the fix
3. FINALLY: Verify the test passes
4. COMMIT: Include both test and fix in the same PR
Example:
/// Regression test for PP-1234: Download button shows after book is returned
/// This test verifies the fix by ensuring state updates propagate to UI.
func testPP1234_BookReturn_UpdatesButtonState() {
// Arrange: Book in downloaded state
let mockRegistry = TPPBookRegistryMock()
let book = TPPBookMocker.mockBook(distributorType: .EpubZip)
mockRegistry.addBook(book, state: .downloadSuccessful)
let viewModel = MyBooksViewModel(registry: mockRegistry)
// Act: Return the book
viewModel.returnBook(book)
// Assert: Button state reflects the change
let buttonState = BookButtonMapper.map(
registryState: mockRegistry.state(for: book.identifier),
availability: nil,
isProcessingDownload: false
)
XCTAssertNotEqual(buttonState, .downloadSuccessful)
}1. FIRST: Write tests that define the expected behavior
- Test the happy path
- Test edge cases
- Test error conditions
2. THEN: Implement the feature to make tests pass
3. FINALLY: Refactor while keeping tests green
4. COMMIT: Tests and implementation together
- New/modified code has corresponding tests
- Tests use real production classes (not testing mocks)
- Mocks are only used for dependency injection
- No network calls in tests
- Tests are deterministic (no random data, fixed dates)
- Bug fix tests include ticket reference
- Tests follow naming convention:
test[MethodOrFeature]_[Scenario]_[ExpectedResult]
- Real production classes - ViewModels, Models, Services, Utilities
- Business logic - State transitions, calculations, validations
- Error handling - Network failures, invalid input, edge cases
- Encoding/decoding - JSON serialization, model parsing
- Computed properties - Derived values, formatting
- Mock implementations themselves
- Basic Swift operations (Set.insert, Array.filter)
- UIKit/SwiftUI framework behavior
- Third-party library internals
// Test file naming: [ClassName]Tests.swift
// Test class naming: [ClassName]Tests
final class MyViewModelTests: XCTestCase {
// MARK: - Properties
private var sut: MyViewModel! // System Under Test
private var mockDependency: MockDependency!
// MARK: - Setup/Teardown
override func setUp() {
super.setUp()
mockDependency = MockDependency()
sut = MyViewModel(dependency: mockDependency)
}
override func tearDown() {
sut = nil
mockDependency = nil
super.tearDown()
}
// MARK: - Tests
/// Tests that [specific behavior] when [condition]
func testMethodName_WhenCondition_ExpectedResult() {
// Arrange
let input = "test input"
// Act
let result = sut.methodUnderTest(input)
// Assert
XCTAssertEqual(result, expectedValue)
}
}// For async/await methods
func testAsyncMethod_ReturnsExpectedResult() async {
let result = await sut.asyncMethod()
XCTAssertEqual(result, expected)
}
// For Combine publishers
func testPublisher_EmitsExpectedValue() {
let expectation = XCTestExpectation(description: "Publisher emits")
sut.$publishedProperty
.dropFirst()
.sink { value in
XCTAssertEqual(value, expected)
expectation.fulfill()
}
.store(in: &cancellables)
sut.triggerChange()
wait(for: [expectation], timeout: 1.0)
}// CORRECT: Mock isolates the real class under test
func testViewModel_LoadsData() {
let mockRepository = CatalogRepositoryMock()
let viewModel = CatalogViewModel(repository: mockRepository) // Real class
XCTAssertNotNil(viewModel.lanes)
}
// WRONG: Testing the mock itself
func testMockRepository_ReturnsData() {
let mock = CatalogRepositoryMock()
mock.searchResult = someFeed
XCTAssertEqual(mock.searchResult, someFeed) // Tests nothing useful!
}Goal: Establish testing infrastructure and patterns
- Create
TPPBookMockerwith deterministic test data - Create
TPPBookRegistryMockwith full protocol support - Create
CatalogRepositoryMockfor catalog tests - Create
MockImageCachefor image-related tests - Document testing standards in
TESTING.md - Create this migration plan
Goal: 85% coverage on all ViewModels
| ViewModel | Status | Owner |
|---|---|---|
BookDetailViewModel |
In Progress | - |
MyBooksViewModel |
In Progress | - |
CatalogViewModel |
Done | - |
HoldsViewModel |
Done | - |
AccountDetailViewModel |
In Progress | - |
CatalogSearchViewModel |
Done | - |
CatalogLaneMoreViewModel |
Done | - |
FacetViewModel |
Done | - |
SettingsViewModel |
Pending | - |
ReaderSettingsViewModel |
Pending | - |
Goal: 90% coverage on core business logic
| Class | Priority | Status |
|---|---|---|
TPPBookRegistry |
Critical | Pending |
MyBooksDownloadCenter |
Critical | Pending |
AccountsManager |
High | Pending |
TPPAnnotations |
High | Pending |
BookButtonMapper |
Done | - |
BookCellModelCache |
Done | - |
AudiobookBookmarkBusinessLogic |
Medium | Pending |
TPPLastReadPositionSynchronizer |
Medium | Pending |
Goal: 75% coverage on network layer with mocked responses
| Component | Status |
|---|---|
TPPNetworkExecutor |
Pending |
TPPOPDSFeedFetcher |
Pending |
TPPAnnotationsManager |
Pending |
CatalogRepository |
Partial |
These classes affect core user flows and are high-risk for regressions:
-
TPPBookRegistry- Central state management- State transitions
- Book addition/removal
- State persistence
- Publisher emissions
-
MyBooksDownloadCenter- Download management- Download initiation
- Progress tracking
- Cancellation
- Error handling
-
BookButtonMapper- UI state mapping- All state combinations
- Availability handling
- Processing states
AccountsManager- Multi-library supportTPPAnnotations- Bookmark syncHoldsViewModel- Hold managementCatalogViewModel- Catalog browsing
- Reader business logic
- Audiobook playback logic
- Settings persistence
Located in PalaceTests/Mocks/:
| Mock | Purpose | Supports |
|---|---|---|
TPPBookRegistryMock |
Book state management | Full protocol, publishers |
CatalogRepositoryMock |
Catalog API | Search, load, cache |
CatalogAPIMock |
CatalogAPI protocol | fetchFeed, search, error injection |
NetworkClientMock |
NetworkClient protocol | URL stubbing, error injection, call tracking |
MockImageCache |
Image caching | TenPrint covers |
TPPBookMocker |
Test book creation | All distributor types |
When creating a new mock:
- Implement the full protocol - Don't skip methods
- Add tracking properties - Call counts, last parameters
- Support error injection - For testing error paths
- Add to Mocks/ directory - Keep organized
- Document in this file - Update the table above
Template:
@MainActor
final class MyServiceMock: MyServiceProtocol {
// MARK: - Tracking
var methodCallCount = 0
var lastParameter: String?
// MARK: - Configuration
var resultToReturn: Result?
var errorToThrow: Error?
// MARK: - Protocol Implementation
func myMethod(param: String) async throws -> Result {
methodCallCount += 1
lastParameter = param
if let error = errorToThrow {
throw error
}
return resultToReturn ?? defaultResult
}
// MARK: - Helpers
func reset() {
methodCallCount = 0
lastParameter = nil
resultToReturn = nil
errorToThrow = nil
}
}All PRs must pass:
- Unit tests - All tests green
- Coverage check - No decrease in coverage
- Lint check - SwiftLint passes
Tests should:
- Run on iOS Simulator (iPhone 14 Pro, iOS 16.1+)
- Complete in < 2 minutes
- Have no network dependencies
- Be deterministic (same result every run)
If a test is flaky:
- Immediately quarantine - Skip with
XCTSkip("Flaky - PP-XXXX") - Create ticket - Document the flakiness
- Fix within 1 sprint - Or delete the test
- Never ignore - Flaky tests erode confidence
1. Check if the area has existing tests
2. Read TESTING.md for standards
3. Identify what needs to be tested
1. Write failing test with ticket # in comment
2. Verify test fails
3. Implement fix
4. Verify test passes
5. Run full test suite
6. Commit test + fix together
1. Write tests for expected behavior
2. Include happy path + edge cases + errors
3. Implement feature
4. Refactor with tests green
5. Run full test suite
6. Commit tests + feature together
test[Unit]_[Scenario]_[ExpectedBehavior]
Examples:
- testButtonMapper_DownloadingState_ReturnsInProgress
- testViewModel_NilURL_DoesNotLoad
- testCache_MemoryWarning_ClearsEntries
// Testing state changes
mockRegistry.setState(.downloading, for: bookId)
XCTAssertEqual(viewModel.buttonState, .downloadInProgress)
// Testing async operations
await viewModel.load()
XCTAssertFalse(viewModel.isLoading)
// Testing error handling
mockRepository.errorToThrow = TestError.networkError
await viewModel.load()
XCTAssertNotNil(viewModel.errorMessage)
// Testing publishers
let exp = expectation(description: "Publisher")
viewModel.$state.dropFirst().sink { _ in exp.fulfill() }.store(in: &cancellables)
viewModel.triggerChange()
wait(for: [exp], timeout: 1.0)PalaceTests/
├── ViewModels/ # ViewModel unit tests
│ ├── BookDetailViewModelTests.swift
│ ├── HoldsViewModelTests.swift
│ ├── AccountDetailViewModelTests.swift
│ ├── FacetViewModelTests.swift
│ ├── CatalogSearchViewModelTests.swift
│ └── CatalogLaneMoreViewModelTests.swift
├── CatalogUI/ # Catalog-related tests
│ └── CatalogViewModelTests.swift
├── MyBooks/ # MyBooks tests
│ └── MyBooksViewModelTests.swift
├── Performance/ # Performance & cache tests
│ └── BookCellModelCacheTests.swift
├── Mocks/ # Shared mock implementations
│ ├── TPPBookRegistryMock.swift
│ ├── CatalogRepositoryMock.swift
│ └── MockImageCache.swift
├── Network/ # Network layer tests
├── Audiobook/ # Audiobook tests
├── Reader/ # EPUB reader tests
├── PDF/ # PDF reader tests
└── OPDS2/ # OPDS2 feed tests
Last updated: January 2026 Maintainer: Palace iOS Team