Testing with intent: What belongs in each test level
Writing a test is rarely the hard part—deciding what that test should actually verify is where most of the thinking happens. Once you know which behaviors matter to your users and which internal details you can safely ignore, the value of each test level becomes much clearer. Here is a practical guide to structuring unit, integration, and end-to-end tests so they catch real regressions without becoming a maintenance burden.
Patterns that apply to every test level
Regardless of whether you are writing a unit, integration, or end-to-end test, certain principles hold true for all of them. Treat these as the default rules, then branch out only when a specific level demands it.
Keep tests simple and flat
Your production code already occupies a substantial amount of cognitive load. Tests that add complexity on top of that only reduce your ability to reason about failures when they occur. The simpler a test is, the faster you can tell what broke and why when it fails.
Many seasoned developers follow what is sometimes called the Golden Rule of testing: a good test should read like an assistant's note, not like a proof of a mathematical theorem. If you need to study the test to understand its purpose, it is too convoluted.
Simplicity is best achieved through a flat test design. Each test covers exactly one scenario and focuses on one specific behavior or feature. When a test fails, the scope of possibilities is small enough that diagnosis is quick. This style also keeps your suite honest—tests carry meaning rather than existing purely to inflate coverage numbers.
Do not couple tests to implementation details
Implementation details are anything your code's users—whether an end user or another developer—would not see or interact with directly. Writing tests against those internals commonly produces two misleading outcomes: false negatives and false positives.
A false negative happens when your test fails because you refactored internal code, even though the observable behavior is still correct. A false positive occurs when your test passes even though the actual user-facing behavior is broken. Both erode trust in the suite and slow you down.
The fix is to design tests around what users actually experience. When you need selectors in component or end-to-end tests, prefer stable hooks like dedicated data attributes over brittle CSS class names that are prone to change during design tweaks or refactors.
Mock carefully, especially at higher levels
Mocking lets you simulate dependencies or external components so you can test a piece of code in isolation. This is valuable for improving predictability, separating concerns, and speeding up execution—and sometimes it is outright necessary, such as when a scenario would otherwise require human involvement.
Still, mocks are approximations of reality, not reality itself. Every mock diminishes how closely your test matches the real user experience. Use them intentionally, not by default. In unit tests this trade-off is generally acceptable. In integration or end-to-end tests, replacement reduces confidence in what the test proves.
An external service that frequently breaks or is out of your control is one notable exception. If a payment provider sandbox is down and blocking your test, you may decide to mock that dependency so your suite is not held hostage by an unrelated vendor. Do this sparingly and with your eyes open to what you are no longer verifying.
Crafting each test level
General principles give you a foundation, but each testing type has its own expectations and its own definition of a well-formed test case.
Unit test criteria
A good unit test is narrowly focused and runs without any external involvement. Here are the marks of one that will serve you well:
- Covers a specific aspect of a single function, method, or component.
- Runs in isolation, with no dependence on external systems.
- Works with small, contained scenarios.
- Has a descriptive name that states the intent at a glance.
- Follows an Arrange-Act-Assert (AAA) structure if applicable.
- Contributes to meaningful coverage of the code it targets.
Integration test criteria
Integration tests share the discipline of unit tests but add a layer of complexity because they verify how components work together. When building one, prioritize the following:
- Exercises interactions and communication between real components.
- Models real-world usage flows rather than artificial setups.
- Uses mocks or stubs only where necessary.
- Reflects on the performance implications of the tested interactions.
End-to-end test criteria
End-to-end tests carry the highest stakes because they validate the full journey through your system. To keep them resilient and meaningful, make sure they:
- Reproduce genuine user actions and behaviors.
- Cover only critical paths that justify the cost of running them.
- Cross service and component boundaries where the real risk lives.
- Handle asynchronous operations explicitly and wait for actual outcomes.
- Verifies the resulting state by checking observable output or data.
- Looks at whether the scenario still performs reasonably under its real load.



