While writing your application, making changes to the existing code, or adding new features, it is very important to get good feedback. How do you know that the feedback you get is good enough? It should accomplish the AEIOU principles:
- Automatic: Getting the feedback should be as painless as possible. Getting it by running just one command is always preferable to having to test your application manually.
- Extensive: We should be able to cover as many use cases as possible, including edge cases that are difficult to foresee when writing code.
- Immediate: You should get it as soon as possible. This means that the feedback that you get just after introducing a change is way better than the feedback that you get after your code is in production.
- Open: The results should be transparent, and also, the tests should give us insight to other developers as to how to integrate or operate with the code.
- Useful: It should answer questions such as "Will this change work?", "Will it break the application unexpectedly?", or "Is there any edge case that does not work properly?".
So, even though the concept is quite weird at the beginning, the best way to test your code is… with more code. Exactly! We will write code with the goal of testing the code of our application. Why? Well, it is the best way we know to satisfy all the AEIU principles, and it has the following advantages:
- We can execute the tests by just running one command from our command line or even from our favorite IDE. There is no need to manually test your application via a browser continually.
- We need to write the test just once. At the beginning, it may be a bit painful, but once the code is written, you will not need to repeat it again and again. This means that after some work, we will be able to test every single case effortlessly. If we had to test it manually, along with all the use cases and edge cases, it would be a nightmare.
- You do not need to have the whole application working in order to know whether your code works. Imagine that you are writing your router: in order to know whether it works, you will have to wait until your application works in a browser. Instead, you can write your tests and run them as soon as you finish your class.
- When writing your tests, you will be provided with feedback on what is failing. This is very useful to know when a specific function of the router does not work and the reason for the failure, which is better than getting a 500 error on our browser.
We hope that by now we have sold you on the idea that writing tests is indispensable. This was the easy part, though. The problem is that we know several different approaches. Do we write tests that test the entire application or tests that test specific parts? Do we isolate the tested area from the rest? Do we want to interact with the database or with other external resources while testing? Depending on your answers, you will decide on which type of tests you want to write. Let's discuss the three main approaches that developers agree with:
- Unit tests: These are tests that have a very focused scope. Their
aim is to test a single class or
method, isolating them from the rest of code. Take your
Sale
domain class as an example: it has some logic regarding the addition of books, right? A unit test might just instantiate a new sale, add books to the object, and verify that the array of books is valid. Unit tests are super fast due to their reduced scope, so you can have several different scenarios of the same functionality easily, covering all the edge cases you can imagine. They are also isolated, which means that we will not care too much about how all the pieces of our application are integrated. Instead, we will make sure that each piece works perfectly fine. - Integration tests: These are tests with a wider scope. Their aim is to
verify that all the pieces of
your application work together, so their scope is not limited to a
class or function but rather includes a set of classes or the whole
application. There is still some isolation in case we do not want
to use a real database or depend on some other external web
service. An example in our application would be to simulate a
Request
object, send it to the router, and verify that the response is as expected. - Acceptance tests: These are tests with an even wider scope. They try to test a whole functionality from the user's point of view. In web applications, this means that we can launch a browser and simulate the clicks that the user would make, asserting the response in the browser each time. And yes, all of this through code! These tests are slower to run, as you can imagine, because their scope is larger and working with a browser slows them down quite a lot too.
So, with all these types of tests, which one should you write? The answer is all of them. The trick is to know when and how many of each type you should write. One good approach is to write a lot of unit tests, covering absolutely everything in your code, then writing fewer integration tests to make sure that all the components of your application work together, and finally writing acceptance tests but testing only the main flows of your application. The following test pyramid represents this idea:

The reason is simple: your real feedback will come from your unit tests. They will tell you if you messed up something with your changes as soon as you finish writing them because executing unit tests is easy and fast. Once you know that all your classes and functions behave as expected, you need to verify that they can work together. However, for this, you do not need to test all the edge cases again; you already did this when writing unit tests. Here, you need to write just a few integration tests that confirm that all the pieces communicate properly. Finally, to make sure that not only that the code works but also the user experience is the desired one, we will write acceptance tests that emulate a user going through the different views. Here, tests are very slow and only possible once the flow is complete, so the feedback comes later. We will add acceptance tests to make sure that the main flows work, but we do not need to test every single scenario as we already did this with integration and unit tests.