Although I possess a Computer Science degree, I didn’t write a single test throughout college. Therefore I technically graduated with a whopping test coverage of 0%. I don’t recall the testing of software being covered during a single one of my classes. At the time, this seemed totally normal, but now it’s hard to believe.
Testing is a large topic in software engineering and it’s a topic that I learned about much too late. My aim with this article is to cover the basics of testing in an easily comprehensible way that would’ve greatly benefited college Kevin.
Why Test?
To begin discussing what tests are, we must first discuss why they may be of interest to you. Tests help you ensure that your code is working “correctly”. I say “correctly” because how you write your tests and what you decide to test for directly determine the usefulness of your test.
Tests are similar to insurance in that they’re a cost you can opt to pay. For the price of writing and maintaining your tests, you can learn when something in your code breaks before users figure it out for you. Without tests (i.e. insurance), allowing these bugs to affect production and users can be extremely costly.
Tests are also a key contributor to the concept of “shift left”, a philosophy aimed at identifying and resolving issues as early as possible in the software development cycle. The sooner you identify a problem, the easier and less expensive the issue is to fix. Now that I’ve convinced you to buy insurance, let’s discuss the different forms of testing.
Types of Testing
While there are many different types of tests the three most frequently discussed are unit tests, integration tests, and end-to-end tests. Unit tests test how individual units of code function (i.e. turning the car key clockwise in the ignition starts the car’s engine). Integration tests test how multiple pieces of code integrate or interact together (i.e. pressing the car’s gas pedal increases airflow in the engineer causing the car to accelerate). End-to-end tests test entire code paths, comprised of multiple components end-to-end (i.e. the car can be turned on, accelerate, drive to the store, and park).
Generally speaking, the more tests a system has (assuming they’re properly written) the more confidence you can have that your system is behaving as intended. The catch is that tests take time to write and maintain. The larger the tests the more expensive it is to develop and maintain. This is why a largely accepted convention, exemplified by the following testing pyramid is to emphasize unit tests, integration tests, and then end-to-end tests in that order.
While there are many different types of tests, most tests exhibit the same structure consisting of three parts: setting up the test, running the test, and examining the results. This paradigm is so common it’s referred to as the three A’s of testing: Arrange, Act, and Assert.
Tests Increase Confidence
Writing tests give engineers confidence to develop and refactor. This was something that took me a while to understand. The reason for this is that when tests are green (i.e. passing) they are a guarantee that the existing behaviours of your system are intact. Therefore if you’d like to refactor code (i.e. change code without alerting functionality) you can do so freely as you can always re-run the test suite to ensure you haven’t broken anything.
To drive this point home, imagine you had a method, mutiplyByTwo(int x)
and as its name suggests it simply returns x
’s value multiplied by two. If this method was tested, you could refactor its implementation from return x * 2
to return x « 1
and tests would still pass. On the other hand, if you modified its implementation and accidentally adversely affected its intended behavior (i.e. it now returns the incorrect value) this method’s test would fail and inform you of this fact.
When behavior doesn’t change (i.e. mutiplyByTwo(int x)
’s responsibility is still to multiply by two), tests shouldn’t change. When behavior does change (i.e. mutiplyByTwo(int x)
’s responsibility is now to multiply by three), tests should change.
Tests’ ability to inform you of problematic changes is a strong motivator to keep your test suites fast. The faster your tests are the more likely you are to run them often and running your tests often helps ensure your code changes are safe.
Writing Tests
One of the biggest epiphanies I experienced that helped me understand the importance of testing started with a bug. My company’s referral software wasn’t handling a specific scenario as we expected from a product perspective. This was strange since previously this scenario worked as expected.
After tracing through the code I realized that there wasn’t a flaw in the logic of the code but instead, there was no existing logic to handle this scenario. It turned out that a previous change had caused a regression and since no test case existed to preserve this expected behavior the change was submitted none the wiser.
Once I told my manager this he suggested I “write a test to prove the existence of the bug”. Confused, he helped me understand by explaining that before modifying the logic to handle the expected scenario, I could write a test that arranges the exact scenario of the regression, acts by calling the method containing the problematic logic and asserts what we should expect to be the result. This is a great example of how test cases serve as a source of documentation to understand how code is expected to behave in different scenarios.
After doing this and running the test it failed, but this was a good thing. The test failure proved the existence of the bug as my manager suggested. I then updated the logic, reran the test, and saw the test turn green. As an important note, always ensure that when writing tests your test first fails. It’s by seeing your test turn from red (failing) to green (passing) that you can be sure your test is useful.
When learning to code we’re taught about DRY — Don’t Repeat Yourself. This goes out the window with testing. The reason for this is production code and test code serve different purposes. Test code is for developers and should be optimized for readability and comprehension. One way to help ensure this is by repeating yourself.
You might be tempted to wrap commonly repeated lines of code throughout different tests in a helper method, but I shy away from this. While there’s no hard and fast rule consider the cognitive load of the reader. I find it easier to understand a test if everything I need to know exists within the test method itself as opposed to needing to jump between different helper methods.
Final Thoughts
All software is tested either by you or your users. Tests are insurance. Good software is tested and always aims to shift left. Writing tests is a crucial element in delivering any feature. Feature work is not complete without its accompanying tests. Congrats, you’ve now learned everything college Kevin wishes he knew.
This article was probably longer than 5 minutes please file a bug to Kevin Naughton Jr.
Drop a like ❤️ and comment below if you made it to the end of the article.
I've had the unique experience to have worked on 2 very different teams. The first team didn't believe in tests with unit test code coverage at a measly 10%. The tests were not prioritized and we were flooded with production support tickets, which went on forever. The second team enforced testing requirements to an extreme degree. I absolutely love that about the second team. Code would not ship if the tests were not in the same MR (even if that means a story being carried into the next sprint). We amassed over 95% code coverage! The production support tickets were far lower than they were on the first team. We also weren't scared to change existing code because we could rely on our strong testing foundation to catch any errors.
This is very relatable with my situation right now.
It’s more challenging to work with code with minimal test coverage, and even more when it has no documentation, and ever more when the person who created it had resigned.