The merit of writing tests in UI engineering ❤️🔥
I have always considered automated testing as part of the must-have best practices in software engineering. The sole purpose of writing tests is to make engineers feel confident before shipping code. "Use as much machine power as possible, do not waste brain power" - is my favourite quote on testing from the most brilliant guy I have met. And I would like to jot down my thoughts on why it happens to many codebases that tests are not well written or even no tests at all. Yet, I think there is a way to fix it if we have a will. Stay tuned and read on guys 🥳
First and foremost, let's highlight two focusing points in this post:
- Why do we need to write tests?
- WHAT, HOW, and WHY should we write tests?
Why do we need to write tests?
7 years ago, I started fresh in software engineering. I did not know anything about testing at all. I am a full-time self-taught developer, nobody told me about testing before that. I started working for a medium size company where no single line of tests has ever been written except automation testing team - aka Quality Assurance (QA). Everyone just wrote code, had it run on their favourite browsers, and clicked around to see whether their tasks were achieved or not. Well, I thought that was it - no testing. Yet, one thing that bothered me a lot was the number of bugs we kept receiving weekly whenever we get into the bug review section. I knew something was deadly wrong but nobody could explain it to me. Most people in the room just said "Software has bugs, so we fix them". Until today, I still completely agree with that with a major difference - how to avoid the same bugs occurring over time - aka regression.
A few months after I kick-offed my career, I was extremely lucky as I was picked up by a colleague at work who is a friend of the most talented software engineer I have ever met. They wanted me to join a side project of him. The guy was one out of five Google Developer Experts in my country. In my first meeting with him, I was super nervous as I never met anyone that smart in person. Yet, I was eager to learn from him - I even studied before the day I met him 😂 I was able to qualify for most of his requirements to join the project except one - its testing. I had no idea what he was talking about. Then, he spent two hours of explanation and demonstrating the way he tests everything he has ever written. That's exactly what we needed at work!
Software is designed to solve human problems. The problems we have will never be a set of fixed problems, yet rather change as time goes on. Hence, the bar of meeting business requirements and code quality at scale is hard. Data Structure & Algorithm, SOLID and Design Patterns are must-have for every successful codebase I have ever encountered. Even with these in our tool belt, it is still tremendously hard to get things right in the first place. I do not know any software that does not have bugs, lack of capacity, or further potential improvements. To error is human, we will make mistakes for sure. The key difference between novice and professional engineers is how they tackle those mistakes. Refactoring is one of the best ways for increasing code quality at the codebase regardless of its scale. Yet, even before refactoring can happen, we need tests!
Before you start refactoring, make sure you have a solid suite of tests. These
tests must be self-checking.
The statement above is quoted from one of my favourite books in programming written by the legend Martin Fowler. Refactoring - Improving the Design of Existing Code. I have never seen any successful software libraries without tests - take React, Vue, @Tanskstack/query, Solid, et cetera. It's even a part of their code conduct of contribution to passing the existing tests and/or having test coverage on changes. I often found myself spending triple the time on testing code versus developing code. It's because the tests make sure all my requirements will be automatically met, the number never lies unless you tell a machine to do so 😅. Take dataloader.js library for instance, the code itself is only 500 LOCs, yet the LOCs for tests are more than double.
WHAT, HOW, and WHY should we write tests?
Below is my favourite talk about What, How, Why by Simon Sinek - he is a great influencer. The talk explains the difference among those three and highlights the importance of WHY which is often neglected by many of us. Thus, I believe the concept is universally applicable to anything - testing in this case
Let's start with the most important one - WHY. It is not uncommon that I have seen developers try to write tests just to cover their code without being able to reason about it. There are sample tests written previously and they copy + paste, then modify some variable names to fit their use cases. It feels so reluctant that way. If you can't give meaning and love what you do, it's almost impossible to deliver a great job. You already fail before you even start. The talk below explains exactly that - highly recommended to watch it
The important lesson I learned from many talented engineers about testing is that. It supposes to give us confidence, nothing more. It's exactly that. Why does someone even bothers to write a test in the first place (Test Driven Development - TDD), then write the code to make them pass? By doing that, they feel 100% confident that they are doing exactly what is necessary to achieve their goals. They instruct the machine to do the work by leveraging their brain power - and the machine is designed to do precisely that. It not only does protect your sleep at night while your code running in all kinds of environments but also supports other developers to contribute code at scale. Unless the submitted code is well-covered by test cases, they should not be able to merge code to the shared branches such as dev / master / main / canary / etc. Hence, confidence gives our writing tests a lot of meaningful purposes which in turn makes us love what we do.
Secondly, it is the easiest one in my opinion - WHAT. Very often, before we start writing any line of code, we already know WHAT - it's the problems we try to solve. If it's an application, it's your business requirement. If it's a reusable library, you intend to help other developers achieve their tasks
Last but never least, it's HOW. This is where I struggled the most in the past because testing tools are not available much for UI engineers like me. Thanks to the huge success of the web community, we had healthy competition and the winning tools benefit us all. It has become much easier to write tests nowadays and runtime is so fast. There are many kinds of tests such as unit tests, smoke tests, end-to-end tests, static typing analysis, performance tests, stress tests, visual tests, et cetera. It depends on your project's standard to focus on those differently. Yet, I found myself involved in integration tests mostly and end-to-end only when integration tests hit its limitation and the most crucial ones such as authentication and payment flows. However, testing practice varies among people. We don't have such a thing like SOLID and Design Pattern to follow in testing. Yet, our feeling is never wrong. Whatever practice could make you feel so good given the same effort? You should stick to it. Over the years, I have seen several pitfalls as below:
- Test all functions, classes, methods, and 100% code coverage is the goal - I could not disagree more on this. And I would say even 100% code coverage does not guarantee bug-free. There are always things not available in your code's control such as third-party dependencies, network latency, browser engine implementation, side effects (stuff rendered on the screen), et cetera. I strongly believe we should always start focusing on WHAT to test instead. As long as your use cases are met, you not only likely meet 100% code coverage but are also able to detect potential not covered code (even able to delete them - one of the best feelings of mine when it comes to coding 😎)
- Testing implementation details. As I said above, nobody can get things right in the first place. The same goes for UI testing. It is quite common to do shallow rendering in your tests because you think it's faster. Unfortunately, it could easily lead to terrible misleading of your tests. Let's say your modal component needs to use a child button component to close on click. Shallow rendering will stub out the button which makes it behave falsely. Correctness over Performance is what I'm trying to address here. It's exactly why premature optimization which is the root of evil. Also, you want to check whether certain methods got called per users action - or event trigger the chain calls manually via method call instead of mimicking users' real behavior. Many things could go wrong here - what if you change the name of the method? What if you decide to use completely another approach to achieve the same goal? The tests will be broken. Accordingly, you will need to update the tests for that purpose and someone will have to review that too... In my opinion, the good code is the one that optimize for change
- Snapshot testing everything. I believe snapshot testing has its own place. It's a double-edged sword just like many powerful things out there. It can cut you badly if you don't use it probably. Justin Searls shared his opinion on snapshot testing here. Yet, others found it effective when using it right. I personally don't like it much. At Axon Global, we banned snapshot testing years ago. We did not find the mearning of it. Most of the time, developers make changes to the code, UI changes very frequently and things break. Most people just updated without knowing WHY -since it doesn't encode developers' intention, a bunch of generated files. It's so fragile that a simple CSS class could break it easily 🥲
- Unnecessary mocking. I typically often find myself mocking only two things - IO and Animation. As they are very unpredictable at runtime and you want to your testing loop iterates as fast as possible. Things like reading a file, API call, animating appearance on the screen, et cetera. In some rare cases, you might want also mock third-party modules and the expensive rendered component too since those are not needed in your tests. Third-party dependencies should be trusted in the first place before you decided to integrate with them. The tests you write might want to make sure it calls their public API correctly as per manual instructions.
The two practices I have followed for years and haven't seen it's going anywhere are from these two legendaries:
- The more your tests resemble the way your software is used, the more confidence they can give you - Kent C. Dodds
- Write tests. Not too many. Mostly integration - Guillermo Rauch
And this is my borrowing testing trophy for life made by Kent
I cannot recommend his course enough for anyone who wants to start with testing on his course - Testing JavaScript. Even if you cannot afford it for whatever reason, please feel free to check out the course code on his Github repo. That's how I learned anyway because I couldn't afford it either financially 🙈 Yet, it is stunningly worthy. I became a better programmer by reading his code only. If your companies could pay for that, I'm happy for you 🥳
Hopefully, by reading until this point, you guys could agree with me more or less on the importance of testing. I'm happy to help with whatever questions you have. Please feel free to contact me for further concerns. Testing is great and everyone should learn it. Surely, it is hard, takes time and patience to develop the right practice. Yet, the right way is usually the hard way. Happy coding ❤️🔥