Have you ever been part of a team meeting, and the conversation moved towards Unit Testing and writing some Integration Tests here and some Acceptance Tests there? And did you feel like you were the only person in the room who didn’t know the difference?
Then you’re probably not alone.
The truth is, many developers use these terms in different ways, which makes it impossible to know what they are exactly talking about.
But when it comes to automated testing, you will almost certainly stumble upon these terms. So, let’s bring some clarification to the table.
Unit Tests
A Unit Test (UT) verifies if one of the smallest pieces in your software works as expected.
The smallest piece in your software is usually a function or a class.
Here’s an example of a Unit Test that verifies if a function kgToLb
converts kilograms to pounds:
// converters.test.js
import { kgToLb } from './converters'
describe('kgToLb', () => {
it('converts kilograms to pounds', () => {
expect(kgToLb(1)).toBe(2.205)
expect(kgToLb(0.4536)).toBeCloseTo(1.000)
})
})
And here is the corresponding implementation of the function kgToLb
:
// converters.js
export const kgToLb = kg => kg * 2.205
Integration Tests
An Integration Test (IT) verifies whether a cluster of units works together nicely.
When one of your functions calls another of your functions, then this would be a cluster of units. But a cluster can also be made of one of your classes that interacts with other classes. Any combination is possible.
Whenever your test interacts with more than just one unit – directly or indirectly, it becomes an Integration Test.
Here’s an example of an Integration Test that verifies that a class Member
, initialized with kilograms, can also provide its weight in pounds:
// fitness/Member.test.js
import Member from './Member'
describe('Member', () => {
it('provides weight in kilograms and pounds', () => {
const member = new Member(75)
expect(member.weightInKg).toBe(75)
expect(member.weightInLb).toBe(165.375)
})
})
And here is the corresponding implementation of the class Member
:
// fitness/Member.js
import { kgToLb } from './converters'
class Member {
constructor(weightInKg) {
this.weightInKg = weightInKg
}
get weightInLb() {
return kgToLb(this.weightInKg)
}
}
export default Member
The subject under test is the class Member
that has a getter function weightInKg
. And the getter function uses the function kgToLb
to convert the member’s weight into pounds.
Two units are involved here: the class Member
and the function kgToLb
. That means the test cannot be a Unit Test – it must be an Integration Test.
Acceptance Tests
An Acceptance Test (AT) verifies if the whole application works as intended.
Sometimes Acceptance Tests are also called User Acceptance Tests (UAT). The question is: Would the user accept the software? To answer that, an Acceptance Test must use the same interface as the user would use. When we talk about a web app, the test needs to use a browser to interact with the app.
Another common name for Acceptance Test is End-to-End-Test (e2e). That’s because an Acceptance Test always tests an app all the way from one end (the frontend) through the middle (the backend) to the other end (the database).
Here’s an example of an Acceptance Test that verifies that the user ends up seeing a dashboard after a successful login:
describe('Feature: Authentication', () => {
it('Scenario: Login', () => {
cy.visit('/login')
cy.type(cy.findByLabelText('Username').type('JaneDoe'))
cy.type(cy.findByLabelText('Password').type('P@ssw0rd'))
cy.findByRole('button', { name: 'Login' }).click()
cy.findByRole('heading', { name: 'Dashboard' }).should('exist')
})
})
Let’s summarize that:
Unit Tests | verify the behavior of the smallest pieces in your software |
Integration Tests | verify that clusters of units work together |
Acceptance Tests | verify the behavior of your whole app end to end |
Unit Tests can turn into Integration Tests quickly
Also, note that a Unit Test can turn into an Integration Test as soon as the function’s or classes’ implementation changes.
Here’s a test that verifies that a function extractEvenNumbers
returns a string that contains only even numbers:
// even.test.js
import extractEvenNumbers from './even'
describe('extractEvenNumbers', () => {
it('removes odd numbers', () => {
expect(extractEvenNumbers('1,2,3')).toBe('2')
expect(extractEvenNumbers('2,4')).toBe('2,4')
expect(extractEvenNumbers('1,3')).toBe('')
})
})
The test above is a Unit Test. It tests one function, and there are no other units involved:
// even.js
export const extractEvenNumbers = numbers => numbers
.split(',')
.filter(number => number % 2 === 0)
.join(',')
Now let’s assume that in our project, there is already a function that can decide whether a number is even or not:
// utils.js
export const isEven = number => number % 2 === 0
The test stays the same, but we can simplify the implementation by using this existing function:
// even.js
import { isEven } from './utils'
export const extractEvenNumbers = numbers => numbers
.split(',')
.filter(isEven)
.join(',')
We did not touch the test code, but the Unit Test has now become an Integration Test. It still verifies whether extractEvenNumbers
works as expected, but it also tests if the two functions extractEvenNumbers
and isEven
play together nicely.
Whether a test is a Unit Test or whether a test is an Integration Test depends on what it tests and not so much on how you call it.
The world is messy
As mentioned at the beginning of this article, not all developers use the terms Unit Test, Integration Test, and Acceptance Test according to the definition above. That can lead to confusion.
The term Unit Test, for instance, is often used to refer to all automated tests in the test suite, regardless of whether the tests are Unit or Integration Tests. And some teams use the term Integration Test to describe a test that verifies if one sub-system of the app integrates with another sub-system of the app (e.g., the backend and the database).
When you join a new team, it’s perfectly ok to ask for their definition and how they use these terms.
See you around,
-David