2026 update: This problem has compounded with AI test generation. See The Hidden Cost of 100% Test Coverage for why AI-generated tests make the happy-path bias worse.
Here’s a pattern I see in nearly every codebase I review: dozens of unit tests, all green, all passing, and almost all testing the same thing — the happy path.
[Fact]
public void CreateUser_WithValidInput_ReturnsUser() { ... }
Yes. Correct. Also: the least likely failure mode.
The tests that catch real bugs live in the places nobody likes to look.
Happy path is table stakes
A test that confirms “valid input produces correct output” is the minimum. It tells you the code works when everything goes right. Most production incidents happen when something goes wrong.
The ratio in most codebases:
- 80% happy path tests → catch ~20% of bugs
- 20% negative/boundary tests → catch ~80% of bugs
This isn’t a precise measurement. It’s a directional truth from years of watching what actually breaks in production.
Negative testing: what happens when it goes wrong
Negative tests validate that the system handles invalid, unexpected, or malicious input correctly.
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void CreateUser_WithInvalidName_ThrowsValidationException(string? name) { ... }
[Fact]
public void TransferFunds_InsufficientBalance_ReturnsError() { ... }
[Fact]
public void GetUser_NonExistentId_Returns404NotFoundResult() { ... }
For every happy-path scenario, ask:
- What if the input is null? Empty? Whitespace?
- What if the referenced entity doesn’t exist?
- What if the user doesn’t have permission?
- What if the downstream service is unavailable?
- What if the request is malformed?
These are the scenarios your users will trigger. The question is whether you find out from a test or from a Sev-1 incident.
Boundary testing: the edges where bugs hide
Boundary conditions are where off-by-one errors, overflow bugs, and edge-case logic failures live.
[Theory]
[InlineData(0)] // minimum
[InlineData(1)] // just above minimum
[InlineData(99)] // just below maximum
[InlineData(100)] // maximum
[InlineData(101)] // just above maximum
[InlineData(-1)] // below minimum
public void SetQuantity_BoundaryValues_BehavesCorrectly(int quantity) { ... }
The classic boundary test pattern: min, min+1, max-1, max, below min, above max. This catches the < vs <= bugs that happy-path tests never touch.
For date-based logic:
- End of month (28th, 29th, 30th, 31st)
- Leap year (Feb 29)
- Midnight / start of day / end of day
- Timezone transitions
- Epoch (Jan 1, 1970) and year 2038
For collections:
- Empty collection
- Single element
- Collection at max size
- Null collection vs empty collection
Data-driven testing: test the matrix
When a function’s correctness depends on input combinations, data-driven testing is the most efficient way to cover the space.
Instead of writing 10 separate test methods:
[Theory]
[MemberData(nameof(GetDiscountTestCases))]
public void CalculateDiscount_VariousScenarios_ReturnsExpected(
CustomerType type, decimal orderTotal, int loyaltyYears, decimal expected)
{
var result = _calculator.CalculateDiscount(type, orderTotal, loyaltyYears);
Assert.Equal(expected, result);
}
public static IEnumerable<object[]> GetDiscountTestCases()
{
yield return new object[] { CustomerType.Regular, 100m, 0, 0m };
yield return new object[] { CustomerType.Regular, 100m, 3, 5m };
yield return new object[] { CustomerType.Premium, 100m, 0, 10m };
yield return new object[] { CustomerType.Premium, 100m, 5, 15m };
yield return new object[] { CustomerType.Premium, 0m, 10, 0m }; // zero order
yield return new object[] { CustomerType.Regular, -50m, 0, 0m }; // negative (boundary)
}
Each test case is a row. Adding a new scenario is adding a row, not writing a new method. The test logic is defined once. The data defines the coverage.
When to use data-driven tests:
- Business rules with multiple input variables
- Validation logic with known-good and known-bad inputs
- Math/calculation functions with expected results
- Status transition tables
The uncomfortable question
Look at your test suite. Count the tests. Now categorise them:
| Category | Count | % |
|---|---|---|
| Happy path | ? | ? |
| Negative (invalid input, error handling) | ? | ? |
| Boundary (edge values, limits) | ? | ? |
| Data-driven (input matrix) | ? | ? |
If happy path is over 50%, your suite is optimised for comfort, not confidence. The bugs you’re afraid of are in the categories you’re underinvesting in.
Specifying what “meaningful” looks like is the same discipline behind prompt engineering as specification — communicating intent precisely, whether to a developer or an AI agent.