You've all hear the crowd harping about how great testing is. But has anyone ever told you what testing is? Or what it means? Or how to even approach testing?
Don't worry, young padawan. I'll guide you through the basics of testing, the different types of testing and how to code in a Test-Driven Development style in Angular.
Hint: You can find all the completed code for this article here.
Consider this: If you're a car manufacturer, are you going to sell cars when you don't know whether or not it works? If you're a good car dealer then of course you'll make sure that it works in all the expected conditions. Why is software any different?
Testing gives developers the confidence that our code will work 100% of the time as expected in the expected conditions. If not, then at least our code can fail gracefully (more on that later). Here's a really nice (but slightly long) video on how TDD relates to good code and professionalism.
You've actually unconsciously done this type of testing in all the code that you've written! Regression testing formally refers to checking if changes to one part of the code has affected any other parts.
We might unprofessionally know this as making a change, seeing something else break and following the breadcrumbs of broken code until everything works.
This type of testing will make up at least 65% of your test suite. It's focused on testing individual components. When I say "components" here, I don't mean Angular or React components, I'm just referring to single, small, individual pieces of logic.
That doesn't mean we're going to test each and every function but we test those pieces of code that are most important (which are usually those focused around business logic).
So for example, in an Inventory Management System, we'll want a test to ensure discounts are applied to certain items.
We know our individual components work individually, but we also need to make sure they don't break when we put them together. This is what Integration Tests are for.
In our Inventory Management System, we'll want tests to make sure that a restocking order is places when inventory on a certain item falls below a certain amount. These tests may combine Inventory Counts and an Ordering System.
End-to-End (e2e) Testing
The applications we write usually have a start point (for example, a login) and an endpoint (for example, a purchase). Testing our apps from start to finish (or from end to end) is critical as this is as close to real-world usage as automated testing can get.
You'll want to take more customer-driven scenarios into these tests such as navigation within the app to ensure the user is still authenticated or if animations and error messages pop up after certain actions.
There are probably more types of tests but these mentioned are the most common.
Test-Driven Development simply means to write our tests before we write our code. Since most of us haven't grown up with TDD in mind, it sounds pretty absurd. Why write the tests first when there's no code to start with?
The reason is that it keeps us very focused on what the code is supposed to do and nothing more. In a way, we subconsciously do this when we write our code, but we don't put down our thoughts into tests.
We usually start off with what the code is supposed to do in our heads, write the code in our IDE and then assume it works. Writing out tests gets those initial thoughts out of our heads and into a form that's more concrete.
Let's do a simple example. We want to write a function that accepts an object, capitalizes the value in the "name" key and returns a new object.
You can fork this StackBlitz repo and code along.
We'll write an empty function first and then write our tests.
We know what we want our code to do, so let's write the corresponding test. If you're using Jasmine, the first unit test should look something like this. Remember, we have an empty function so the first test should fail.
And the resulting failing test:
We expect that the
actualResult should be the same as the
expectedResult. This is the basis of all tests. As long as our expectations match what is actually produced, then our tests will pass.
Now we can modify the code so that the test passes.
We've just done TDD! We thought about what the code needed to do, wrote the test first and then wrote the code to make the test pass.
Our code above works fine but it assumes that the object:
- is defined
- has a key called "name"
- has a defined value in the key called "name"
- has a string value in the key called "name"
When writing functions, you may not know where your arguments may come from (perhaps from sources that you cannot easily control such as form data or from an HTTP request). You have to be prepared for a number of cases like those described above so that it is robust. The more assumptions you make, the more room for error that you leave in your code.
Let's throw in some more test cases and see what happens:
Our tests are failing again so we know the areas that we need to work on.
I've decided to include a name key with an empty string if the name isn't available. I've also decided to throw an error if the name key in the object is not a string or if the object is falsy. Let's modify the code so that it works in those cases.
And now all our tests are passing:
Test-Driven Development allows us to write simple, yet robust code. It teaches us to consider many cases upfront as opposed to just the way the code is supposed to work. This way, the code isn't prone to breaking at all, or at least as often.
Tests also act as a good form of documentation. Running the tests on a codebase and seeing all the test cases gives us a pretty good indication of what the code is supposed to do.
Unit test descriptions tell us what each piece of logic is supposed to do. Integration tests tell us how pieces are supposed to connect together. End-to-end tests tell us what to expect when using the entire system.