Testing Like a Pro: Strategies for Reliable Code
Post-mortem reflections on code reviews I’ve conducted at Microsoft have revealed recurring lessons about what makes testing truly effective.
Testing is where the rubber meets the road in software development. It’s not just about verifying functionality — it’s about building confidence in your code and ensuring it behaves as expected under all the right conditions (and even the wrong ones). But testing isn’t something you rush into blindly.
You need a strategy that balances practicality, simplicity, and thoroughness. Over the years, I’ve developed an approach that focuses on starting small, keeping tests honest, and balancing the competing priorities of encapsulation and testability. This isn’t about writing tests for the sake of it — it’s about writing tests that matter, that tell you something meaningful about your code, and that leave your project better than they found it.
Unit Tests: Crawl Before You Walk, Walk Before You Run
I’m a huge believer in the “crawl, walk, run” philosophy of testing. You don’t start with the full integration test that spans five services, an asynchronous task, and three third-party APIs. You start with the smallest possible unit of work — a unit test.
When my mentee jumped straight into integration tests, I had to pull back the reins. “You’re trying to run before you’ve even crawled,” I said. “Start with the fundamentals. Write a test for a single class. See if it works in isolation. Then move to the larger integration scenarios once the small parts are rock solid.”
This approach forces clarity. It strips away distractions like configuration issues, environment setup, or complex dependencies. If your unit test is failing, it’s because something small and specific isn’t working — not because some tangential service didn’t respond. Unit tests are also a place to explore edge cases. What happens with a null
value? A negative number? An empty list? It’s where we hammer out how the code should behave under all possible conditions.
Atomicity in Tests: Reducing Local State
Unit tests should be atomic, meaning they should execute in complete isolation from one another. When I saw a lot of class-level state being used in the tests, I said, “This is a lot of local state. Unit tests should be atomic. Use local variables wherever possible instead of class members.”
Tests that rely on shared state are fragile and prone to side effects. By keeping the state local to each test, you ensure the tests are isolated, repeatable, and easier to debug.
Good Tests Are Honest Tests
In a test, don’t hide behind iteration when you’re really testing specific cases. If you’re testing a method that should succeed with valid inputs and fail with invalid ones, write separate tests for those scenarios. Copy and paste isn’t your enemy here — it’s your friend.
When I saw my mentee write a loop to test several inputs, I asked her, “What are you trying to prove? If you’re testing one thing, test one thing. If you’re testing five things, write five tests. It’s clearer that way. And by the way, why not just hardcode the inputs? You don’t need to overthink a test.” The same goes for cleanup. Tests should leave the world as they found it. If your code writes to files, make sure those files are deleted when the test is done. Otherwise, you’re creating side effects that might not show up until they break something later.
Encapsulation and Testing: Finding the Balance
One point of discussion was around making a private member variable public solely for the sake of unit testing. “You made this public because you wanted the test project to access the internal member _secrets,” I pointed out. “There’s a better way to handle this. Use the special mechanism in .NET to grant internal access to the test project at the assembly level. That way, you’re not compromising encapsulation for testing convenience.”
The balance between testability and encapsulation is subtle, but it’s critical. Code should remain maintainable and follow good design principles even while accommodating tests.
Configuration vs. Hardcoding: Be Intentional
Not everything needs to be configurable. Configuration is a tool, not a mandate. I told her, “Configuration is for things you want to change at runtime. If you’re not expecting it to change, don’t spend the time abstracting it. Hardcoding can be perfectly fine for constants.”
This doesn’t mean avoiding configuration altogether, but the decision to make something configurable should be intentional, not reflexive. The balance lies in anticipating actual use cases, not hypothetical ones.
Conclusion
Good testing is an art, not a checkbox on a list. It’s about clarity, intentionality, and building trust in the systems you’re creating. When tests are small, atomic, and focused, they become powerful tools for maintaining quality and understanding the behavior of your code. By resisting the urge to over-engineer test cases or sacrifice encapsulation for convenience, you ensure your tests are assets, not liabilities. Ultimately, the goal of testing isn’t perfection — it’s confidence. And when you’ve got that, you’re well on your way to building reliable, maintainable software that stands the test of time.