You’ll read this test five times more than you’ll write it. Future-you will thank present-you for mocking it readable.
This article distills and expands the talk I gave internally at Criteo: Readable Unit Tests. It’s aimed at developers who know unit testing but avoid it because tests feel hard to write, hard to maintain, and — let’s be honest — often ugly. Let’s fix that.
TL;DR
Readable tests are:
- Behavior‑focused (test what the code does, not its guts)
- Resilient to refactoring (survive reasonable changes)
- Self‑contained and obvious (you can understand them at a glance)
- Consistent (follow familiar patterns, so your brain’s pattern‑matcher lights up)
Eight concrete practices help you get there:
- Divide tests into given / when / then
- Use good test names with clear conditions
- Keep everything in the test (avoid shared setup/teardown)
- Generate test data for non‑significant values
- Use creation functions to hide noisy details (especially mocks)
- Prefer test data classes when parameters get complex
- Consider scenario classes for readable end‑to‑end setup
- Get feedback — what’s readable to you today may not be tomorrow
What’s a “unit” anyway?
A unit test targets a small, isolated piece of behavior — typically a method, class, or rule. There are two characteristics that all unit test must have that guide how “large” a unit we can handle.
- Unit tests (all tests for that matter) need to be reliable. Ideally eliminating external factors that are out of our control. This generally means no external services, and nothing that could be holding state. Unit tests can interact with shared state like in-memory databases to test sql, if the tests are constructed well. In-memory databases are fast, easy to set up, and teardown between tests. It’s best when it is possible to have multiple instances so tests are more isolated from each other.
- Unit tests must also be fast, otherwise they won’t be run frequently and they lose a lot of their value. Unit tests should be able to be run every code edit. Good guidance is that they should take 10 seconds or less.
There is then the question of how large a unit should be? Which generally boils down to a tension between clarity and resilience:
- Bigger, API‑level tests can be resilient to change (still work after refactoring code) but often obscure what’s being exercised.
- Smaller, focused tests are clearer but can be fragile if they poke at implementation details.
It is best to do a combination of both. Lots of smaller tests that are used to exhaustive verify that all code paths work as expected. Usually this requires a lot of mocking. The bigger tests validate that the interfaces between classes are working correctly. (We are mocking correctly.) They function like an internal integration test for your classes.

Strong opinions, lightly held
- See the test fail once. Red → Green → Refactor. If you never saw red, you don’t know the test works.
- Layer your tests. Have requirement‑level tests and focused unit tests.
- Bugs hide in the dark code. Aim for thorough coverage, but remember: assertions determine test quality.
- Code that’s hard to test is bad code. Refactor it.
- Great developers write great test code. Make tests a first‑class citizen in your codebase.
The Readability Rules (with examples)
1) Divide tests into given / when / then
arrange / act / assert is another common naming convention. The goal is to use comments or blank lines to make structure obvious.
[Test]public void Check_ShouldRejectSku_WhenIsNotInStock(){ // given var context = GenContext(); var now = DateTime.UtcNow; var outOfStockSku = GenSku(context) with { Quantity = 0 }; var skuChecker = new SkuChecker( CreateConfiguration(context), CreateLineItemCache(now, outOfStockSku)); // when var response = skuChecker.Check(context, now, outOfStockSku); // then Assert.That(response, Is.EqualTo(RejectReason.OutOfStock));}
Why?
- The structure makes the test easy to comprehend.
- The structure keeps developers from combining multiple tests into a single test.
2) Name tests like you mean it
Pick a convention and stick to it. I prefer:
<Method>_Should<DoThing>_When<Conditions>
Also name variables for meaning: outOfStockSku beats sku.
Why?
- Good names are critical to understanding what the test is doing.
- A good name can be more revealing than a comment.
- Developers often skim-read code and a good name can stand out and greatly increase understanding.
3) Keep everything in the test
Avoid class‑level setup/teardown that scatters context across files. Build the SUT inline so a reader sees exactly what matters for this test.
Why?
- When a test fails, the developer doesn’t need to look elsewhere for critical information.
- Class level setup methods often contain setup details that don’t apply to the current test and understanding which details are important can be difficult to decipher.
4) Generate test data for non‑significant values
Use a small helper so non‑important values don’t distract.
using Ploeh.AutoFixture;public static class TestHelpers{ private static readonly Fixture Fixture = new(); public static T Gen<T>() => Fixture.Create<T>(); public static CandidateSkusContext GenContext( int minMatchLevels = 0, string? placementTaxKey = null) => Gen<CandidateSkusContext>() with { KeywordTags = KeywordTags.Empty, MinMatchLevels = minMatchLevels, NormalizedQuery = null, NormalizedBrandName = null, PlacementTaxKey = placementTaxKey ?? Gen<string>(), };}
The prefix Gen is a convention that indicates that the value is (mostly) generated.
The Gen<Type>() function is just a wrapper around Fixture.Create<T>() that’s easier to read.
Why?
- This tells the reader they don’t have to care about what the specific value is.
- When the reader then sees a specific value, they know that it has meaning, it is important to calculate a result or controls the flow of operations.
5) Use creation functions to hide noise
Mocks and complex graphs are pure visual noise. Hide them behind small helpers so tests stay focused on behavior. Making arguments optional with reasonable defaults means that readers will only have to see the arguments that matter for the test.
private static IServiceSkuPlacerConfig CreateConfig( CandidateSkusContext context, bool isOpenAuctionDayPartingEnabled = false, int startDateDelayMinutes = 0){ var mockConfig = new Mock<IServiceSkuPlacerConfig>(); mockConfig.Setup(o => o.IsOpenAuctionDayPartingEnabled(context.RetailerId)) .Returns(isOpenAuctionDayPartingEnabled); mockConfig.Setup(o => o.GetStartDateDelayMinutes(context.RetailerId, "sp")) .Returns(startDateDelayMinutes); return mockConfig.Object;}
Why?
- Knowing that a type of object is created is often all a developer needs to know.
- Encourages sharing of the methods to create objects.
6) Use test data classes when parameters explode
When inputs become a combinatorial beast, structure them.
public class TestRevenueModelData{ private readonly string _name; public TestRevenueModelData(string name) { _name = name; } public override string ToString() => _name; public bool DisableFblByScoreTypeAndDataId { get; init; } public bool UseFeedbackLoopFactor { get; init; } public bool EnableTargetCpcFblFactor => UseFeedbackLoopFactor; public double? ExpectedRevenueScoreRawPredictedValue { get; init; } public bool IsWhitelisted { get; init; } = true; public ExtractedScores? RealtimeRevenueScoreRaw => RealtimeRevenueScoreRawPredictedValue == null ? null : new ExtractedScores(RealtimeRevenueScoreRawPredictedValue.Value);}
Giving a human-readable name to the test data is important!
Then feed cases via a source:
private static IEnumerable<TestRevenueModelData> TestCases(){ yield return new TestRevenueModelData("1. isSalesOptimizationStrategy") { UseFeedbackLookFactor = true, RealtimeRevenueScoreRawPredictedValue = 1.5, ExpectedRevenueScoreRawPredictedValue = 1.5, };}
Why?
- A
TestCase()with lots of parameters become very difficult to know which value corresponds with which test method parameter. This style allows names to be clearly associated with their values. - Allows more complex objects to be parameters to the test.
TestCase()only supports simple types. - Allows defaults, so only the values significant to the test need to be seen.
7) Consider scenario classes for readable setup
For complex flows, a fluent scenario keeps intent obvious.
[Test]public async Task GetPlacementsAsync_ShouldReturnAllPlacements(){ // given var s = new PlacementScenario() .RequestSkuId() .RequestUserSkuIds(count: 2) .RequestTaxonomyBestOf() .RequestGlobalBestOf() .RequestKeywords() .ReturnedSkusForEachRequestType(count: 2); var servicePlacer = new ServiceSkuPlacer( s.CreateBrandNameStorage(), s.CreateRecoReaderFactory(), s.CreateKeywordReaderFactory(), s.CreateCandidateSkusSelectorFactory(), s.CreateRecoSourcePrioritizerFactory(), s.CreateServiceSkuPlacerMetrics()); // when var result = await servicePlacer.GetPlacementsAsync(s.Request); // then Assert.That(result, Is.EqualTo(s.ExpectedResult)); s.VerifyPlacementMetrics(comps: 6, sims: 6, taxs: 2, bests: 2, keywords: 2);}
Why?
- When graphs of objects become complex it can be hard to see what is important and what is not. This allows those details to remain hidden.
- Method names can better describe what the graph of objects looks like.
8) Get feedback (or go back)
Ask teammates to read your tests. If they squint, rename, refactor, or restructure. Future‑you will thank present‑you.
Why?
- Code always seems readable when you first write it.
- Reviewing and getting feedback is the best way to get better at writing readable code.
Assertions vs Coverage
Coverage finds the “dark code,” but assertions prove correctness. High coverage with weak assertions is false confidence. Balance both.
Avoid these anti‑patterns:
- Too many assertions in one test (unclear intent; fragile)
- Hidden setup in base classes (readability crater)
- Over‑mocking external systems (use creation helpers and test public behavior)
- Parameterized tests with conditional logic inside the test (split into separate tests)

Layering tests that stay readable
- Requirement‑level tests: exercise public behavior through real objects; verify the happy path and essential edges.
- Focused unit tests: isolate rules and calculations; make assertions at the point where meaning lives.
Refactor mercilessly. If you struggle to test some code, redesign it.
A quick checklist
- [ ] Clear
given / when / then - [ ] Test name states method, intent, and conditions
- [ ] SUT is constructed inline in the test
- [ ] Non‑significant values are generated
- [ ] Noisy details hidden behind creation functions
- [ ] Use test data classes or scenarios where appropriate
- [ ] Assertions validate what matters (not just “it ran”)
- [ ] Someone else read it and didn’t squint

P.S. on style and voice
Yes, I sprinkle helper methods like Gen and Create*. I start local; if I repeat them across files, I centralize (with care). I also like descriptive strings as the first parameter in parameterized cases so IDEs show human‑friendly names.
Readable tests are about empathy: help your future teammates — and future you — understand what matters without fighting the scaffolding.
Thanks for reading. Now go make some tests you’re proud of.




