Get your software development to the next level with these 5 powerful principles of unit testing! Testing your code is one of the most important aspects of software development, as it ensures quality, scalability, and reliability of your product.
But, without any guidelines, it can be difficult to write effective tests. In fact, testing code can become more complex and harder to maintain than the actual production code!
Don't let this happen to you. Follow these best practices for unit testing, which can be applied to any framework or library, both in the backend or frontend of your application, and you'll be able to write lean, accurate, and easy-to-read testing code. With these, you'll save valuable dev time, avoid technical debt, and enjoy testing your code:
1. Lean and accurate testing
Testing code must be simple and easy to work with. Anyone looking at the test should know instantly what the test is about and what its purpose is. Developing tests should bring great value with very little effort and time investment.
Do you need more than 30 seconds to read and understand your test? Rewrite it!
Testing just for ‘coverage percentage’? Don’t!!! This just makes the majority of the test suite to be unnecessary and undesirable for the developers. Test only what’s needed. It’s better to drop some tests for agility and simplicity, testing only the main business logic and principal edge cases.
2. Test the behaviour, not the implementation
Don’t check every single line and internal variable changed in the code. When testing, you should focus on the result. The result should always remain the same even if the code inside the method is refactored!
This way, you won’t need to rewrite your tests if the code base is changed.
// Wrong ❌ - Test behaviour
describe('Evaluation Service', () => {
describe('Register Students', () => {
it('Should add new students to the evaluation service', () => {
const studentJosh = {
id: 1,
name: 'Josh McLovin',
average: 6.98,
}
evaluationService.addStudent(studentJosh)
expect(evaluationService._students[0].name).toBe('Josh')
expect(evaluationService._students[0].average).toBe(6.98)
})
})
})
// Right ✅ - Test behaviour
describe('Evaluation Service', () => {
describe('Register Students', () => {
it('Should add new students to the evaluation service', () => {
const studentJosh = {
id: 1,
name: 'Josh McLovin',
average: 6.98,
}
evaluationService.addStudent(studentJosh)
expect(evaluationService.getStudentAverage('Josh')).toBe(6.98)
})
})
})
3. Test naming and structuring. AAA pattern.
Have you ever had a failing test named ‘it should [...] correctly’ and lost a couple of minutes finding where the problem was?
Naming and structuring your test suite can enhance your ability to address any failing tests swiftly and accurately, ultimately saving you valuable time. So, let's dive into two key principles to keep in mind for your next testing endeavours:
3.1 Thoughtful test naming:
When naming your tests, attempt to incorporate the following information:
- What is being tested?
- Under what circumstances?
- What is the expected outcome?
// Right ✅ - Test naming
// 1. What is being tested:
describe('Evaluation Service', () => {
describe('Evaluate Students', () => {
// 2 & 3. Context and expected result
it('If the student grade is below the minimum grade, student should be suspended', () => {
const students = [
{ name: 'Mark', grade: 4.25 },
{ name: 'Colin', grade: 6.7 },
{ name: 'Ben', grade: 5.3 },
]
const result = evaluationService.evaluateStudents({ students, minGrade: 5 })
expect(result['Mark']).toBe('suspended')
})
})
})
3.2 The AAA pattern for testing code structure:
If you want to maintain a readable and easily comprehensible test suite, structure the test as it follows:
- Arrange: set up all the code needed to simulate the required situation. This can include initialising variables, mocking responses, instantiating the unit under test, etc.
- Act: execute what is being tested, usually in a single line of code.
- Assert: check the obtained result is the expected one. Like the one above, this should take only one line.
// Right - AAA Testing Pattern
describe('Evaluation Service', () => {
describe('Average Calculation', () => {
it('Should calculate the average grade of all the students', () => {
// Arrange: create an object with the student names and their grades
const students = [
{ name: 'Mark', grade: 4 },
{ name: 'Colin', grade: 10 },
{ name: 'Ben', grade: 7 },
{ name: 'Tim', grade: 3 },
]
// Act: execute the getAverage method
const avg = evaluationService.getAverage(students)
// Assert: check if the result is the expected one -> (4+10+7+3)/4 = 6
expect(avg).toEqual(6)
})
})
})
4. Deterministic and isolated tests
If a single failing test turns your entire suite red, then you may not be approaching it in the right way!
Tests should be independent and isolated, targeting and dealing with one specific logic at a time, accomplishing a faster and more stable test suite.
What happens if you don’t write your tests independently?
- You won’t be able to pinpoint the exact cause and location of the bugs and issues.
- When refactoring the tests, you will have to update and synchronise multiple ones.
- You won’t be able to run your tests in any order, which may cause breaking or skipping some of the assertions or expectations.
5. Property-based testing and realistic data
Tired of writing a large amount of possible inputs in your tests? Property-based testing does it for you! But… What 's that?
Property-based testing creates hundreds of possible combinations, stressing the test and increasing the opportunity of uncovering previously unnoticed bugs. This approach can even return the inputs which may have caused an unexpected outcome.
Libraries like JSVerify or Fast-Check offer essential tools to facilitate property-based testing.
However, if you prefer not to dig into such extensive testing, it is crucial to utilize realistic data whenever possible. Inputs like 'abc' or '1234' might erroneously pass the test when it should actually fail.
// Wrong ❌ - False Positive - Test that passes even though it shouldn't
class EvaluationService {
_students = [];
addStudent(student) {
// Add the student if the name has no numbers
if(!student.name.matches(/^([^0-9]*)$/)){
this._students.push(student);
}
}
}
describe('Evaluation Service', () => {
describe('Register Students', () => {
it('Should add students to the Evaluation service', () => {
const mockStudent = {
id: 2,
name: 'username',
average: 7
}
// Won't fail because the name is a string without number -> We are not checking what happens if the user
// inputs a name with a number.
evaluationService.addStudent(mockStudent)
expect(evaluationService.getStudentAverage('username')).toBe(7)
})
})
})
// In the example above, we are making a test so the test passes, instead of looking for edge cases with realistic data
Bonus Tip!
If you are getting a hard time testing any logic in your component, this could reflect that, maybe, you should break your component logic into smaller, easier and testable pieces of code!
In conclusion, following this best practices may lead to a new, readable and maintainable way of testing your beloved production code. Thanks for reading!
A tested app is a reliable app!
Top comments (11)
Excellent post. For those interested in this topic, you might also be interested in the F.I.R.S.T Principles of Unit Testing.
I have started using a forth 'A', on top of the 'AAA', to structure unit tests (Arrange, Affirm, Act & Assert). The Affirm stage is like the Assert stage but before the Act is performed. This enables me to a) ensure the test environment is as we expect, which reduces the potential for false positives (and negatives), b) comparing before with after the Act.
Great advices.
Just want to annotate that if one developer still do not have the skills or willing to test, he/she must start only for high coverage, then improve following this guide.
High coverage of not perfect test is better than no test, at least for regresión purpose.
And latest but not less important. WRITE YOUR TEST BEFORE THE CODE
Nice one. I am always happy to see and read through posts about testing and unit testing in particular. The only time I could not nod here was in the beginning of the article where it says „Developing tests should bring great value with very little effort and time investment.“
While tests definitely should bring great value, I’d rather say that any test must be worthwhile and the effort put in justified. In general stating to put little effort and time into them is not necessarily correct but „it depends“.. at least from my experience.
And thank you! Anyone who is aiming at 100% coverage shall please stop doing so. This mostly creates extra effort which is not worth it.
Great article! Lots of good content here
This is an easy-to-understand and great post. If you would like to apply these same principles when building React applications, I recommend this reacttdd.com/.
Super on-point advice. Good work David!
Another good resource on testing is Yoni's JavaScript Testing guide: github.com/goldbergyoni/javascript...
Great article. Congrats
I always say: show me your tests, and I will tell you how complex your code is.
i didn't know much about testing... this is very good article! thanks for sharing
Great article I have read a lot about unit testing but I Still cant find content about the correct way of writing them can you suggest good resources like courses or books?
Some comments may only be visible to logged-in visitors. Sign in to view all comments.