Edge cases are where bugs live. Happy path tests verify that the system works correctly with ideal input. Edge case tests verify the boundaries — the off-by-one errors, the null inputs, the exact boundary of a validation rule, the concurrent modification scenario. Most production bugs are edge cases that were never tested because “that can’t happen” — until it does.
Edge Case Tests
// ── File size boundary tests ──────────────────────────────────────────────
public class FileUploadValidationTests
{
private readonly FileUploadValidator _sut = new(maxSizeBytes: 5_000_000);
[Theory]
[InlineData(0, false)] // empty file
[InlineData(1, true)] // minimum valid
[InlineData(4_999_999, true)] // one byte under limit
[InlineData(5_000_000, true)] // exactly at limit (boundary)
[InlineData(5_000_001, false)] // one byte over limit
[InlineData(10_000_000, false)] // well over limit
public void Validate_SizeCheck_ReturnsExpectedResult(long fileSize, bool isValid)
{
var result = _sut.ValidateSize(fileSize);
result.IsValid.Should().Be(isValid,
because: $"file size {fileSize} bytes should be {(isValid ? "valid" : "invalid")}");
}
}
// ── Pagination boundary tests ─────────────────────────────────────────────
public class PaginationTests
{
[Theory]
[InlineData(100, 1, 10, true, false)] // first page of 10
[InlineData(100, 10, 10, false, true)] // last page of 10
[InlineData(100, 5, 10, true, true)] // middle page
[InlineData(0, 1, 10, false, false)] // empty result
[InlineData(1, 1, 10, false, false)] // single item, fits on one page
[InlineData(10, 1, 10, false, false)] // exactly one full page
[InlineData(11, 1, 10, true, false)] // one item spills to page 2
public void Create_ReturnsCorrectHasNextHasPrev(
int total, int page, int pageSize, bool hasNext, bool hasPrev)
{
var result = PagedResult<string>.Create([], total, page, pageSize);
result.HasNextPage.Should().Be(hasNext);
result.HasPrevPage.Should().Be(hasPrev);
}
}
// ── ViewCount never-negative test ─────────────────────────────────────────
public class ViewCountTests
{
private readonly Mock<IPostsRepository> _repo = new();
private readonly PostsService _sut;
[Fact]
public async Task DecrementViewCount_WhenAlreadyZero_StaysAtZero()
{
var post = new Post { Id = 1, ViewCount = 0 };
_repo.Setup(r => r.GetByIdAsync(1, default)).ReturnsAsync(post);
_repo.Setup(r => r.UpdateAsync(It.IsAny<Post>(), default))
.Callback<Post, CancellationToken>((p, _) => post = p)
.Returns(Task.CompletedTask);
await _sut.DecrementViewCountAsync(1, default);
post.ViewCount.Should().Be(0, because:
"view count must never go negative; minimum is 0");
}
}
// ── Unicode slug generation ───────────────────────────────────────────────
public class SlugEdgeCaseTests
{
private readonly SlugGenerator _sut = new();
[Fact]
public void Generate_WithEmDash_ReplacesWithHyphen()
{
var result = _sut.Generate("API Design—Best Practices");
result.Should().Be("api-design-best-practices");
}
[Fact]
public void Generate_WithConsecutiveSpecialChars_CollapsesHyphens()
{
var result = _sut.Generate("C# & .NET — Together");
result.Should().NotContain("--"); // no double hyphens
}
[Fact]
public void Generate_WithVeryLongTitle_TruncatesAt200Chars()
{
var longTitle = new string('a', 250); // 250 chars
var result = _sut.Generate(longTitle);
result.Length.Should().BeLessThanOrEqualTo(200);
}
[Fact]
public void Generate_WithAllNonAscii_ReturnsEmpty()
{
var result = _sut.Generate("日本語のタイトル");
result.Should().BeEmpty(); // or a fallback UUID-based slug
}
}
file.Length > 5_000_000 (strict greater-than) accepts exactly 5MB, while file.Length >= 5_000_000 (greater-than-or-equal) rejects it. The boundary tests catch this distinction immediately.Moq.Callback() to capture what was passed to a mock method — useful when you need to assert on the object that was saved to the repository. In the ViewCount test, the callback captures the post after UpdateAsync is called, allowing you to assert that ViewCount = 0 (not negative) was written. Without the callback, you would have to use _repo.Verify(r => r.UpdateAsync(It.Is<Post>(p => p.ViewCount == 0), ...)) which is harder to read for complex assertions.Common Mistakes
Mistake 1 — Testing only happy path boundaries (missing off-by-one bugs)
❌ Wrong — test with 4MB file (passes) and 6MB file (fails); never test 5MB exactly or 5MB+1 byte.
✅ Correct — test at, just under, and just over every boundary; catches strict vs non-strict comparison bugs.
Mistake 2 — Not testing empty collections separately from null
❌ Wrong — test returns correct data but never test what happens when the repository returns an empty list vs null.
✅ Correct — test both: ReturnsAsync(new List<Post>()) (empty) and ReturnsAsync((List<Post>?)null) (null).