Unit Testing at Scale (EN)
Unit tests are not about โtesting everythingโ โ they are about creating a fast, reliable feedback loop that protects behavior while enabling change. At Tech Lead/Principal level, the goal is a test suite that is:
- Fast (seconds/minutes, not hours)
- Deterministic (no flakes)
- Readable (acts as executable documentation)
- Resilient (survives refactors)
- High signal (fails for meaningful reasons)
1) Mental model: what a good unit test is
A unit test verifies a small unit of behavior in isolation.
At scale, โunitโ usually means:
- A pure function
- A method with minimal dependencies
- A domain service with dependencies replaced by fakes/stubs
Not unit tests: tests that hit the network, filesystem, database, clock, random generator, static singletons, or global state.
2) The principle: test behavior, not implementation
Behavior: what the component guarantees to consumers (contract). Implementation: how it currently does it.
Prefer assertions like:
- result values
- emitted domain events
- commands sent to collaborators
- published messages
Avoid asserting:
- exact internal method call sequences unless that is the contract
- private state
- exact query strings
Refactor resilience heuristic
If you can refactor the code (rename methods, change internal algorithm) without changing the externally visible behavior, then unit tests should not need to change.
3) Boundaries and seams (how you get testability)
A maintainable unit-test strategy comes from architecture:
- Domain layer: mostly pure logic; easiest to unit test
- Application layer: orchestrates use cases; unit test with fakes
- Infrastructure: prefer integration tests
Design-for-test rules
- Put I/O behind interfaces.
- Inject time (
IClock), randomness (IRandom), IDs (IIdGenerator). - Avoid static state; prefer DI.
- Keep constructors cheap (no heavy work).
4) Patterns that scale
AAA (Arrange/Act/Assert)
Keep each section small and explicit.
Given/When/Then (BDD style)
Same as AAA, optimized for readability.
Test data builders
Create readable object graphs.
public sealed class OrderBuilder
{
private Guid _id = Guid.NewGuid();
private List<OrderLine> _lines = new();
public OrderBuilder WithId(Guid id) { _id = id; return this; }
public OrderBuilder WithLine(string sku, int qty)
{
_lines.Add(new OrderLine(sku, qty));
return this;
}
public Order Build() => new Order(_id, _lines);
}
Parameterized tests
Use for input matrices.
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
public void IsValidQuantity(int qty, bool expected)
{
Assert.Equal(expected, OrderRules.IsValidQuantity(qty));
}
5) Common test smells (and what to do instead)
- Brittle tests: assert on internal details โ assert on outcomes/contract.
- Mystery guests: hidden setup in shared fixtures โ make setup explicit or use builders.
- Assertion roulette: many asserts with no message โ group asserts, use descriptive names.
- Slow tests: heavy object graphs or real dependencies โ mock/fake, move to integration tests.
- Over-mocking: tests mirror implementation โ use state-based testing or higher-level tests.
6) Mocking policy (unit tests)
At scale, you need a clear policy:
- Mock outgoing dependencies (DB, queue, HTTP, filesystem)
- Prefer fakes over mocks for rich dependencies (in-memory repositories)
- Prefer state-based assertions over interaction-based assertions
Example: fake repository + state-based test
public sealed class InMemoryUserRepo : IUserRepository
{
private readonly Dictionary<Guid, User> _users = new();
public Task AddAsync(User user) { _users[user.Id] = user; return Task.CompletedTask; }
public Task<User?> FindAsync(Guid id) => Task.FromResult(_users.TryGetValue(id, out var u) ? u : null);
}
[Fact]
public async Task Register_creates_user_and_returns_id()
{
var repo = new InMemoryUserRepo();
var service = new RegistrationService(repo);
var id = await service.RegisterAsync("a@b.com");
var saved = await repo.FindAsync(id);
Assert.NotNull(saved);
Assert.Equal("a@b.com", saved!.Email);
}
7) Flake-proofing (critical for leads)
Unit tests must be deterministic:
- Never use
DateTime.Nowdirectly โ inject anIClock. - Never use randomness without controlling seed.
- Avoid parallel shared mutable state.
- Avoid real timers.
Clock injection example
public interface IClock { DateTimeOffset UtcNow { get; } }
public sealed class SystemClock : IClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; }
public sealed class FakeClock : IClock { public DateTimeOffset UtcNow { get; set; } }
8) Coverage: how to think about it
Coverage is a metric, not a goal.
Good targets depend on domain criticality:
- Domain logic: aim high (70โ90%+), but focus on critical invariants
- Controllers/infrastructure: lower coverage, more integration tests
Prefer measuring:
- defect escape rate
- time-to-fix
- flaky rate
- runtime of suite
9) Interview angle (Lead-level)
Be ready to answer:
- โWhat makes a good unit test?โ
- โHow do you prevent flaky tests?โ
- โWhatโs your mocking strategy and why?โ
- โHow do you balance unit vs integration tests?โ
Strong TL answer includes:
- a test pyramid strategy
- deterministic rules (clock/random/I/O)
- patterns (builders, parameterized tests)
- a policy for what gets unit-tested
10) Review checklist (for real projects)
- [ ] Unit test suite runs in minutes.
- [ ] No external I/O in unit tests.
- [ ] Clock/randomness are injected.
- [ ] Tests assert on behavior/contract.
- [ ] Builders/factories exist for complex setup.
- [ ] Flaky tests are treated as incidents (fix or quarantine quickly).
- [ ] CI enforces fast feedback (fail fast, parallelize, split slow suites).