One crisp Monday morning, a fresh-faced software engineer, affectionately nicknamed Penguin 🐧, embarked on their inaugural journey into the professional realm, commencing their very first job as a developer. As the young Penguin waddled into the office, eager to make their mark, a captivating conversation unfolded between the newcomer and their seasoned team leader, the wise and experienced Rex 🦖.
Testing a small isolated unit with predictable results
Penguin 🐧: I’ve noticed that we have test cases written for almost all features in our mobile app. Why would we write unit tests if we have a QA team that does testing and quality assurance?
Rex 🦖: Writing unit tests doesn’t just ensure that features behave correctly after being developed or shipped; it also ensures that when someone writes new code, it doesn’t break any existing code. The QA team can’t test everything all over again when a new feature is introduced. It’s like an investment: you spend extra time writing unit tests while developing a feature, but you prevent potential bugs from happening in the future.
A required CI job can block changes that fail the behaviors covered by its tests. It cannot prove that every feature is correct, so meaningful assertions and coverage of important failure paths still matter. Tests can also document the behavior a future reader needs to preserve.
Penguin 🐧: My first function checks if the application needs a force update by comparing two version strings, like 1.0.2 and 1.1.2. Should my test function iterate through all possible cases, from the minimum value 000.000.000 to the maximum 999.999.999?
Rex 🦖: No! The idea of test cases is to cover edge cases and unexpected scenarios, like negative numbers in this example, along with a few random usual cases. You might consider writing test functions like:
testWhenCurrentVersionIsLessThanRequiredVersionRequiresUpdate
testWhenRequiredVersionIsEqualToCurrentVersionRequiresNoUpdate
testMaximumMajorNumberComparesCorrectly
testMaximumMinorNumberComparesCorrectly
testMinimumPatchNumberComparesCorrectly
testMinimumMajorNumberComparesCorrectly
testMinimumMinorNumberComparesCorrectly
testMinimumPatchNumberComparesCorrectly
testMinimumNumbersComparesCorrectly
…plus some random normal cases.
Remember, the naming convention should show the developer’s intention, even if the test function name becomes lengthy. Choose representative cases and meaningful boundaries rather than enumerating an enormous input space without a reason. Keep unit tests fast enough to run frequently, measuring setup and cleanup as well as execution. Integration and UI tests have broader responsibilities and different runtime budgets.
Unit Tests Criteria “F.I.R.S.T”:
The F.I.R.S.T criteria for unit tests is a set of principles designed to promote effective testing practices. It stands for Fast (tests should run quickly), Independent (tests should be self-contained), Repeatable (tests should yield consistent results), Self-Validating (tests should automatically determine pass or fail), and Timely (tests should be written alongside production code). These principles help ensure robust unit tests that improve code quality and simplify maintenance.
- Fast: We can run dozens of them in a second, if not more.
- Isolated: Should not depend on each other or any external state.
- Repeatable: They should always give the same result when they are run, like a pure function.
- Self-Verifying: The test must unambiguously say whether it passed or failed, with no room for interpretation.
- Timely: They should be written before or alongside the production code that you are testing.
Penguin 🐧: But we must have high test coverage, like 100% coverage to cover all cases, right?
Rex 🦖: Coverage reports which instrumented lines, functions, or regions executed, depending on the metric. It does not measure all possible inputs or prove that assertions checked the right outcome. Use it to find missing evidence, then review what the tests actually establish.
Penguin 🐧: Got it. Are there any other benefits to having unit tests?
Rex 🦖: Testing reduces maintenance costs and the number of bugs. There are also other costs to consider, like customer impact. The longer an issue goes undiscovered, the more expensive it is, which can result in negative reviews, lost trust, and, of course, lost revenue!
Penguin 🐧: Why follow TDD (Test Driven Development) methodology? Why write the tests before writing the feature itself?
Rex 🦖: There are many development methodologies, like TDD, ATDD, DDD, BDD… These are lengthy topics, and I encourage you to read about them. While TDD is a popular approach that includes the RGR (Red, Green, Refactor) lifecycle, it’s important to note that it’s not always the best choice for every situation. Consider the context of your project when choosing a methodology.
Penguin 🐧: That’s cool! How can I make sure my code is testable, and what makes it not testable?
Rex 🦖: You may consider architectural patterns that make code more modular and easier to test, like MVVM, VIPER, VIP, or even Functional Reactive Programming (FRP). Dependency injection, coordinator patterns, and pure functions also help make your code more testable.
Penguin 🐧: So I always need to mock stuff when testing, right?
Rex 🦖: There are various types of test doubles out there, not just mocks. You’ve got fakes, stubs, spies, and dummies too! It might feel a bit overwhelming at first, but I encourage you to look into each one and learn when to use them effectively. Understanding their differences will really help you in your testing journey!
(test doubles) term is derived from (stunt doubles)
Penguin 🐧: What other tips do you have?
Rex 🦖: Here are a few:
- Keep unit tests independent of live services. Networking integration tests can deliberately exercise request construction, response handling, or a controlled server boundary with isolated fixtures.
- Do not depend on test execution order. Runners can reorder tests or execute them in parallel; names should describe behavior, and each test should establish its own state.
- Xcode provides performance tests that compare previous runs. You can also change the baseline, and it gives nicely formatted test coverage markers.
- Investigate flaky tests instead of accepting a passing retry as proof of correctness. Control timing and shared state, preserve failure evidence, and check whether the intermittent result exposes a real product race.
Continue with FIRST principles and avoiding flaky tests, or follow all eight articles in the Testing roadmap.