The One TDD Habit That Saved My Sanity (and My Codebase)
Quick context (why you're writing this)
Here's the thing: I used to think I was doing TDD right. I’d write a test, watch it fail, then write just enough code to make it green. Rinse and repeat. Sounds textbook, right?
But a few months ago I spent an entire afternoon chasing a bug that only showed up after I refactored a service class. The tests were all passing, yet the app was throwing NullReferenceExceptions in production. I was shocked. How could everything be green and still be broken?
Turns out I was testing the inside of my code instead of what it actually did for the outside world. That realization hit me like a truck, and it completely changed how I approach TDD.
The Insight
Test behavior, not implementation.
If your test is coupled to private fields, internal data structures, or the exact way a method accomplishes its goal, you’re not testing what matters—you’re testing how you happen to do it today. When you later refactor to improve performance, swap out a dependency, or even just rename a variable, those tests start failing for no good reason. You end up spending more time fixing tests than delivering value, and you lose confidence in the suite because it feels fragile.
The payoff? A test suite that gives you confidence when you change code, not anxiety. You can refactor fearlessly because the tests only care about the contract: given these inputs, the system should produce these outputs or side‑effects.
How (with code)
Let’s look at a tiny but realistic example: a PasswordValidator service that checks whether a user‑chosen password meets our policy.
❌ The mistake: testing implementation details
// PasswordValidator.cs
public class PasswordValidator
{
private readonly IRegexProvider _regex; // injected for testability
public PasswordValidator(IRegexProvider regex)
{
_regex = regex;
}
public bool IsValid(string password)
{
// implementation we might want to change later
return _regex.IsMatch(password, @"^(?=.*[A-Z])(?=.*\d).{8,}$");
}
}
// PasswordValidatorTests.cs – the "implementation‑focused" version
[TestClass]
public class PasswordValidatorTests
{
private Mock<IRegexProvider> _regexMock;
private PasswordValidator _sut;
[TestInitialize]
public void Setup()
{
_regexMock = new Mock<IRegexProvider>();
_sut = new PasswordValidator(_regexMock.Object);
}
[TestMethod]
public void IsValid_ShouldCallRegexWithCorrectPattern()
{
// Arrange
var password = "Abcdef12";
_regexMock.Setup(r => r.IsMatch(password, @"^(?=.*[A-Z])(?=.*\d).{8,}$"))
.Returns(true);
// Act
var result = _sut.IsValid(password);
// Assert
Assert.IsTrue(result);
_regexMock.Verify(r => r.IsMatch(password,
@"^(?=.*[A-Z])(?=.*\d).{8,}$"), Times.Once);
}
}
What’s wrong here?
- The test knows the exact regex pattern.
- It verifies that a private dependency (
IRegexProvider) is called with that pattern. - If I decide to improve the validation—say, add a check for special characters or move the regex into a constant—the test will fail even though the observable behavior (does it accept a strong password?) hasn’t changed.
✅ The fix: testing behavior only
// PasswordValidatorTests.cs – the behavior‑focused version
[TestClass]
public class PasswordValidatorTests
{
private PasswordValidator _sut;
[TestInitialize]
public void Setup()
{
// We don't need to mock the regex provider; we can use the real implementation
// because it's deterministic and fast. If it were slow, we’d inject a fake.
_sut = new PasswordValidator(new RegexProvider());
}
[TestMethod]
public void IsValid_ReturnsTrue_ForPasswordThatMeetsPolicy()
{
// Arrange
var password = "Abcdef12";
// Act
var result = _sut.IsValid(password);
// Assert
Assert.IsTrue(result, "Expected the validator to accept a password with an uppercase letter, a digit, and at least 8 characters.");
}
[TestMethod]
public void IsValid_ReturnsFalse_ForMissingUppercase()
{
// Arrange
var password = "abcdef12";
// Act
var result = _sut.IsValid(password);
// Assert
Assert.IsFalse(result);
}
[TestMethod]
public void IsValid_ReturnsFalse_ForTooShort()
{
// Arrange
var password = "A1";
// Act
var result = _sut.IsValid(password);
// Assert
Assert.IsFalse(result);
}
}
Notice the shift:
- No mocks of internal collaborators (unless they’re truly external, like a DB or HTTP client).
- Assertions only look at the return value of the public method.
- The test names describe the behavior we care about, not the internal steps.
If I later replace the regex with a loop‑based checker or pull the pattern into a configurable file, none of these tests break—as long as the outward contract stays the same.
Why This Matters
I spent three hours debugging that refactor bug because my tests were lying to me. They said “all good” while the system was silently violating its contract. When I switched to behavior‑focused TDD, two things happened almost immediately:
- Confidence went up. I could rename variables, extract methods, or even swap out a third‑party library without worrying that a test would start failing for the wrong reason.
- Feedback got tighter. When a test did fail, I knew it was because the observable outcome changed—exactly what I needed to know to fix the bug or adjust the requirement.
There’s a trade‑off, of course. If you’re dealing with a slow or nondeterministic dependency (think external APIs or hardware), you’ll still need to mock or fake those parts. But even then, you mock at the boundary where the system interacts with the outside world, not at every internal helper method.
The biggest win? Your test suite becomes living documentation. New teammates can read a test and instantly understand what the code is supposed to do, without having to decipher private fields or helper methods.
Wrap‑up
If you take away one thing from this, let it be: write your tests to verify what the system does, not how it does it. Start each feature by asking, “What should the caller see or experience after this code runs?” Then write a test that asserts exactly that—nothing more, nothing less.
Give it a try on your next small task. Write a test that only checks the public output or side‑effect, watch it fail, then make it pass. Notice how you feel when you refactor later and the tests stay green.
What’s the first place you’ll apply behavior‑focused TDD on your current project? I’d love to hear what you discover. 🚀
Top comments (0)