I recently add two more bonus videos for Confident Laravel, this time on Test Driven Development (TDD). These videos demonstrate additional testing tactics and I felt these two videos brought everything together nicely.
As such I wanted to share a written version to at least outline the intricacies of these practices working together.
I'll be honest, TDD isn't something I practice often. There was a time when I wouldn't write a single line of code without writing a test first.
However, I have come to realize that sometimes spikes or even no tests are acceptable.
Said another way I take on the risk. I give up some confidence in the code as the investment of writing tests don't pass a cost benefit analysis. Things like views, certain browser interactions, or even smaller features.
In these videos I wanted to add Purchasing Power Parity to my video courses. Since this directly affects revenue I definitely wanted it tested. So I once again reached for TDD.
From a high-level, here were the steps and reasoning behind testing this feature with a focus on the interlinking between TDD, outside-in, and YAGNI.
Getting started testing
TDD states, I can't write any code until I first have a failing test. So I know that I need to start with a test. But where do I start in the code?
I could start anywhere. I could start with the model. I could start with the controller. I could start with validating the request. I could start with the service to geocode the IP address. I could start with the translation from country to pricing factor.
This is where the practice of outside-in testing comes to help narrow the choices. Outside-in promotes starting with a test at a higher-level (outside) of the application and working our way to a lower-level (inside) the application.
As such this immediately rules out most of the low-level items listed above like the model, translation, and validation.
This really leaves us with the geocoding service or the controller.
Of the two, the controller is the most outside piece. Or looking at it another way, the geocoding service is not going to be used until after a request comes throught the controller.
Keeping it on the level
Now that I know where I want to start testing, I can begin to think about what the controller is going to do.
So I'll write a few tests to drive out this functionality:
- Test the failure response when a remote IP address could not be determined.
- Test when the geocoding service returns an unexpected response
- Test when the geocoding service returns a response, but does not offer purchasing power.
- And finally the happy path, where the geocoding service returns a country which does offer purchasing power.
Going deeper in
Now depending on if I am marking these underlying services or not some of these tests will fail. Either way, I know the code is incomplete and as such need to continue deeper inside the application to finish driving out this feature.
In this case, I typically work from the top down. So I would start with the geocoding service, then work my way to the country/discount mapping.
The geocoding service simply wraps a call to a geocoding API. I want to test a response for when it succeeds and when it fails. These tests might even be more integration style and actually hit the service to verify the response.
Do you really need it?
Now that I've completed this inside code, I move back up to the controller to drive another path. In this case, the process for mapping the country returned by the geocoding service to a potential purchasing power discount.
Now I have infinite choices on the design and as such another area where developers get stuck. So I reach for YAGNI to help guide this next set of code.
Again, it helps to think about the problem at a high-level. Basically, I need to know if there is a coupon for a country.
Given that, I don't need a fancy translation object, service, or even a different model.
Instead, I can treat this as another retrieval operation the existing
Coupon model. Something along the lines of:
This simply accepts the country as an argument and will return a coupon if there is one and
This encapsulates the code enough to make the test easy. But it also prevent me from adding unnecessary complexity by introducing new patterns or objects to the code.
Now I can drive deeper inside the application to the
Coupon model and start writing tests to determine if a coupon exists for the country.
The missing piece
I reached the final piece of the code necessary to complete the feature. Often I find if I have followed these practices correctly, this is the primary action of the code. In this case, is there Purchasing Power.
What's also interesting is these approaches have also made this easier. Or at least the perception this once previously amorphous feature broke the focus into simple, fundamental pieces.
Again I have the opportunity to implement this translation in an infinite number of ways. I may reach for a database solution by adding columns to the
coupons table. But YAGNI argues using a database for a small list key value pairs is over engineering. This is up to you to decide.
I decided to use a simple data object to encapsulate this mapping. From there, the test was pretty straightforward. In fact, I used a PHPUnit data provider to efficiently test every mapping returned the expected coupon.
Upon adding this final piece the entire test suite ran successfully. I practiced TDD to drive out the entire feature. I used the outside-in approach to write code deeper within the application, and YAGNI to keep the code at each layer simple. This left me with a well-tested and developed set of code and ultimately confident it behaves as expected.
Need to test your Laravel applications? Check out the Tests Generator Shift to quickly generate tests for an existing codebase and the Confident Laravel video course for a step-by-step guide from no tests to a confidently tested Laravel application.