Greetings, traveler!
Agent Skills are reusable instruction packages that teach an AI coding agent how to handle a specific type of task. A skill can define a workflow, decision rules, constraints, and reference material that the agent loads when needed.
This article is part of my series about creating practical Agent Skills for iOS development.
The first article explains what Agent Skills are, how they differ from files such as AGENTS.md and CLAUDE.md, and how they can be used in real development workflows.
This time, I want to discuss the iOS Testing Strategy Agent Skill.
What this skill is for
Testing discussions often start with framework-level questions:
- Should this test use XCTest or Swift Testing?
- How should the test method be written?
- Which assertion API should be used?
Those questions matter, but they come after a more important decision: what behavior should be tested, and at which boundary?
The iOS Testing Strategy skill helps an agent answer that broader question. It is intended for designing, reviewing, and improving automated testing strategies in Swift and iOS codebases.
The skill can help determine:
- which behavior deserves protection;
- whether a narrow, feature-level, integration, or system-wide test is appropriate;
- which dependencies should remain real;
- where a fake, stub, spy, or mock is justified;
- how much testing effort a feature deserves;
- whether TDD fits the current stage of development;
- how tests should be distributed between local development and CI.
It deliberately avoids prescribing one testing style for every situation. Its recommendations depend on risk, stability, execution time, determinism, and maintenance cost.
Start with behavior, not types
A common testing mistake is to treat every class as an independent unit that needs its own test file.
This often leads to narrow tests with many mocks. The tests verify that one method calls another method, but they provide little confidence that the feature works when its real components are connected.
The skill starts from a different question:
Which behavior, invariant, or failure risk needs protection?
After identifying that behavior, the agent looks for a stable entry point and an observable result.
That result might be:
- a returned value;
- a state transition;
- persisted data;
- an emitted effect;
- a meaningful error;
- a user-visible outcome.
The structure of the production code does not automatically define the test boundary. A test running in an XCTest or Swift Testing target can exercise one function, a complete feature slice, or several connected domains.
Choosing a test boundary
The skill recommends starting with the broadest boundary that can remain fast, deterministic, and understandable when it fails.
A narrow test works well for isolated calculations, parsers, validators, state transitions, and logic with many input combinations. It gives fast feedback and usually makes failures easy to locate.
A feature-level test is useful when several components implement one meaningful behavior together. It might connect domain logic, use cases, mappers, state containers, effect handling, and an in-memory repository.
An integration test is appropriate when the main risk exists between real technologies or subsystems. Examples include persistence mappings, migrations, request encoding, Keychain access, database constraints, or SDK adapters.
A system-wide test connects several application domains without requiring full UI automation. It can exercise several real domains while replacing only unstable external boundaries.
The broadest test is not always the best test. A boundary should be narrowed when it becomes slow, flaky, difficult to configure, or difficult to diagnose.
The opposite is also true. When mocks reproduce most of the production collaboration, the boundary is probably too narrow.
Real dependencies before test doubles
The skill does not treat mocking as the default approach.
A real implementation can stay in a test when it is lightweight, deterministic, safe, and part of the behavior being verified. Pure domain logic, mappers, validators, reducers, and simple coordinators often meet these conditions.
Keeping them connected reduces test setup and verifies their actual collaboration.
External or unstable boundaries are different. Networking, persistent storage, clocks, randomness, system services, and external SDKs may need controlled replacements.
The skill distinguishes between several kinds of test doubles:
- A fake provides a simplified working implementation with reusable behavior.
- A stub returns predefined values or errors.
- A spy records meaningful interactions.
- A mock defines expected interactions and fails when those expectations are not met.
The recommendation is to use the simplest test double that provides the required control.
For example, an in-memory repository is often more useful than a mock when several operations must interact with stored state. A simple stub may be enough when the test only needs a fixed response.
Mocks are reserved for cases where an interaction is itself part of the contract.
Protocols should represent real boundaries
Testing can push a codebase toward protocol-first design. Every concrete type receives a protocol because someone may want to mock it later.
The skill advises against this approach.
Before introducing a protocol, the agent should consider whether it can:
- use the real implementation;
- inject configuration or input data;
- provide an in-memory implementation;
- widen the test boundary;
- isolate a genuine external dependency.
A protocol makes sense when it represents an external system, several real implementations, replaceable infrastructure, or a concurrency boundary.
A test seam should follow the architecture. It should not create an artificial abstraction around every service or use case.
Prioritizing tests by risk
The skill does not recommend distributing tests evenly across files or modules.
Some behavior deserves deeper protection because failure would be expensive or difficult to detect. Other code may already be covered through a broader scenario or may not justify a dedicated test.
The risk model considers four factors:
- The impact of a failure.
- The probability of regression.
- How easily the failure can be detected.
- The cost of recovery.
Financial calculations, authentication, authorization, migrations, destructive operations, persistence rules, and security-sensitive decisions usually deserve more attention.
Trivial forwarding code, generated code, temporary adapters, and rapidly changing UI composition may receive less direct coverage.
This does not mean ignoring them completely. It means choosing a validation effort that matches the risk.
Testing volatile code
Code volatility does not always mean behavior volatility.
A feature may already have stable business rules while its UI, orchestration, adapters, or module boundaries are still changing. In that case, the stable behavior still deserves protection. The test should focus on an observable outcome rather than the current internal decomposition.
For isolated calculations, parsers, validators, and state transitions, a narrow test is often enough. When stable behavior spans several components, a broader feature-level test may survive refactoring better than a collection of tests tied to temporary collaborators.
Prototypes and architectural spikes need a more selective approach. Tests are useful when they verify the question the experiment is meant to answer, such as ordering in a concurrency model, persistence semantics, handling of a real payload, or the viability of a proposed boundary. Building a complete regression suite around code that will probably be discarded rarely pays off.
When durable automation is premature, the alternative should still be explicit. A team might use focused manual scenarios, instrumentation, sample fixtures, temporary assertions, or a small proof-of-concept integration check. These methods support exploration, but they should not be mistaken for permanent regression coverage.
TDD follows the same rule. It works well when the expected behavior is clear, the inputs and outputs are defined, and the test can describe a durable contract. Parsers, calculations, permissions, state machines, bug fixes, and domain policies are good candidates.
It creates more churn when the team is still discovering requirements, comparing UI flows, or testing whether an architectural idea is feasible. In those cases, writing tests first can freeze assumptions before the problem is understood.
Testing effort should depend on the stability and risk of the behavior, not simply on the age of the code. High-risk rules should not remain untested simply because the surrounding implementation is still moving.
Assertions that survive refactoring
Tests should fail when behavior changes incorrectly. They should not fail because a private helper was renamed or work moved between two internal types.
The skill prefers assertions about observable results:
- final state;
- persisted values;
- returned errors;
- emitted domain events;
- generated external requests;
- recovery behavior.
Assertions about private methods, incidental call order, exact object graphs, or temporary decomposition make tests brittle.
Interaction assertions are still useful when the interaction is the behavior. Analytics events, navigation commands, external requests, and destructive operations are reasonable examples.
Even then, the test should verify the meaningful payload or rule rather than record every internal step.
Keeping the test suite maintainable
A test suite is another codebase that the team must understand and maintain.
Large fixtures, shared mutable state, generic setup helpers, real clocks, arbitrary delays, and repeated application bootstrap can make tests slow and difficult to trust.
The skill encourages small, scenario-specific fixtures and setup that stays close to the test. Shared helpers should reveal intent instead of hiding it.
It also separates tests by feedback loop.
Fast and deterministic tests belong in the local and pull-request workflow. Tests that require real persistence technologies, migrations, larger datasets, or broader integration can run in a slower CI suite.
Moving a flaky test to CI does not fix it. The source of nondeterminism still needs to be found. Longer sleeps and automatic retries usually hide the problem rather than solve it.
The skill also allows tests to be removed. A test no longer provides value when the behavior has disappeared, another test offers the same confidence, or the assertions only protect an obsolete implementation.
Coverage and manual verification
Code coverage tells us which code executed while tests were running. It does not tell us whether the assertions were useful or whether important scenarios were protected.
The skill treats coverage as a diagnostic signal rather than a quality score.
An uncovered branch may reveal a missing error scenario. It may also contain trivial, temporary, unreachable, or already protected behavior. The percentage alone cannot tell the difference.
For the same reason, the skill does not require one coverage target for every module. A payment domain and a temporary presentation adapter should not receive the same testing investment simply because they live in the same repository.
Manual testing also remains part of the strategy.
Automated tests work well for deterministic behavior, repeated regression checks, state transitions, data correctness, and edge cases. Manual verification is still useful for usability, animation quality, visual polish, accessibility experience, and exploratory work.
Choosing not to automate something is still a risk decision. The team should record what remains manual, why automation is not currently worth the cost, and when that decision should be reconsidered.
How the skill is organized
The main SKILL.md file defines when the skill should be used, the core decision workflow, general rules, common mistakes, and the expected response format.
More detailed guidance is split into focused reference files:
test-boundaries.mdexplains how to choose between narrow, feature-level, integration, and system-wide tests.test-doubles-and-real-dependencies.mdcovers real implementations, fakes, stubs, spies, mocks, and appropriate test seams.risk-based-test-prioritization.mdhelps decide which behavior deserves the most testing effort.volatile-code-and-tdd.mddiscusses prototypes, unstable features, architectural spikes, and when TDD is useful.assertions-and-refactoring-resilience.mdfocuses on observable outcomes and avoiding tests coupled to implementation details.test-suite-maintainability.mdcovers fixtures, shared setup, execution speed, flakiness, and test ownership.coverage-and-manual-testing.mdexplains how to use coverage as a diagnostic signal and where manual verification still makes sense.
The skill directs the agent to load only the references relevant to the current task.
For example, a review of excessive mocking may require the files about test boundaries and test doubles. A discussion about coverage targets may only require risk-based-test-prioritization.md and coverage-and-manual-testing.md.
This structure keeps the main skill concise while preserving enough detail for deeper testing and architecture reviews.
The repository also includes configuration for OpenAI-compatible agents, a Claude Code plugin, and a Gemini extension.
What the agent should produce
The skill defines a practical response structure for testing reviews.
The agent should explain:
- Which behavior or risk needs protection.
- Which test boundary it recommends and why.
- Which dependencies should remain real.
- Which boundaries should be replaced.
- Which scenarios and observable assertions matter.
- What the maintenance and execution trade-offs are.
- Where the tests should run.
When reviewing an existing suite, it should also identify unnecessary doubles, implementation-coupled assertions, duplicated setup, missing high-risk scenarios, and slow or flaky boundaries.
This output is more useful than a generic recommendation to “add unit tests.” It connects each proposed test to a concrete risk and explains the reasoning behind its shape.
Installation
The skill can be installed with the following command:
npx skills add Livsy90/iOS-Testing-Strategy-Agent-Skill --all
For a repository-scoped Codex setup, the ios-testing-strategy directory can also be copied to:
.agents/skills/ios-testing-strategy/
Final thoughts
A testing strategy should not begin with a coverage percentage or a list of classes that need mocks.
It should begin with the behavior the team cannot afford to break.
The iOS Testing Strategy Agent Skill gives an agent a repeatable process for identifying testing risks, choosing appropriate boundaries, and using test doubles only where they are needed.
The skill is available on GitHub.
