For a greenfield project, I'd optimize for confidence per test, not coverage percentage. Coverage is a useful diagnostic ("why is this critical code untested?"), but a poor goal on its own.
A strategy that works well for many teams is roughly:
- 70% integration tests
- 20% unit tests
- 10% end-to-end (E2E) tests
The exact numbers matter less than the philosophy: test behavior where components interact, not every implementation detail.
1. Unit tests: use them selectively
Write unit tests for code that's:
- Pure business logic
- Algorithms
- Validation rules
- Complex state transitions
- Date/time calculations
- Pricing, permissions, scheduling, etc.
Don't write unit tests for:
- Simple getters/setters
- Thin CRUD wrappers
- Framework glue
- Mock-heavy tests that simply verify implementation
Good unit test:
Given a subscription expires today,
renewal extends it by exactly one billing period.
Poor unit test:
Verify UserService calls UserRepository.save() once.
The second one breaks every time you refactor while telling you very little.
2. Integration tests should be your workhorse
These give the highest ROI.
Instead of mocking your database, queue, or HTTP layer, spin up the real dependencies (often using containers).
Examples:
- API → database
- Repository → Postgres
- Message consumer → queue
- Authentication middleware
- Transaction boundaries
These catch:
- SQL mistakes
- Serialization bugs
- Configuration errors
- Migration problems
- ORM surprises
They're slower than unit tests but much more representative of production.
3. Keep E2E tests few but meaningful
Think of E2E tests as smoke tests for the whole application.
Aim for major user journeys:
- User signs up
- User logs in
- User purchases something
- User resets password
- Admin creates resource
Not:
- Every validation message
- Every edge case
- Every button
A dozen excellent E2E tests often outperform hundreds of brittle UI tests.
4. Test at the lowest level that gives confidence
Suppose you have an order total calculation.
Instead of:
Browser
↓
API
↓
Service
↓
Database
...test it as:
Service
unless you're specifically validating the API or persistence.
Conversely, if you're worried about authentication, test the whole request pipeline instead of mocking everything.
5. Prefer real dependencies over mocks
Mock:
- Payment gateways
- Email providers
- Third-party APIs
- SMS services
Avoid mocking:
- Your own repositories
- Your own services
- Your own database
Tests with lots of mocks tend to verify your assumptions rather than your software.
6. Don't chase coverage
Instead of saying:
We need 85% coverage.
Ask:
- What production bug could happen here?
- What important behavior lacks protection?
- What regression would be expensive?
Sometimes 40% coverage protects nearly everything important.
Sometimes 90% coverage misses a catastrophic integration failure.
7. Add regression tests for every real bug
One of the best long-term strategies is:
- Bug found
- Write failing test
- Fix bug
- Keep test forever
Your suite gradually becomes a record of the failures your system has actually experienced.
8. Test public behavior, not implementation
This makes refactoring much easier.
Instead of:
expect(cache.invalidate).toHaveBeenCalled()
Prefer:
After updating the user,
the next read returns fresh data.
Behavior survives refactors; implementation doesn't.
9. Keep tests fast enough to run constantly
A healthy split might look like:
| Layer | Runtime | Frequency |
|---|
| Unit | Seconds | Every save / pre-commit |
| Integration | 1–5 minutes | Every PR |
| E2E | 5–15 minutes | Every PR or before deployment |
If developers avoid running tests because they take too long, their value drops quickly.
A pragmatic starting point
For a new project, I'd establish these defaults:
- Write unit tests only for non-trivial business logic.
- Write integration tests for every API endpoint or service boundary.
- Write E2E tests only for critical user flows.
- Use real databases in tests (often via containers).
- Mock only external services you don't control.
- Ignore coverage targets initially; instead, review whether critical behaviors are protected.
- Every production bug gets a regression test.
This approach tends to produce a suite that's smaller, more resilient to refactoring, and much more effective at catching the kinds of failures that actually reach production. It's also aligned with the "testing trophy" philosophy popularized by Kent C. Dodds: emphasize integration tests, use unit tests where they add value, and keep E2E tests focused on essential workflows.