Photo by Misael Moreno

Clean Architecture: Unit Testing

Paul Allies

--

Introduction

Testing is an essential part of Clean Architecture because it helps ensure that the application works as intended and that changes to the codebase do not break existing functionality.

By writing tests for your code, you can catch bugs before they make it into production and cause problems.

Here are a few ways of testing:

  • Unit testing: Testing individual units of code in isolation from the rest of the application.
  • Integration testing: Testing how different parts of the application work together.
  • Automated testing: Using software to run tests automatically, without human intervention.
  • Continuous testing: Using tools such as continuous integration and deployment (CI/CD), you can automatically test your code every time it is updated, ensuring that bugs are caught early and often.

In this blog we’ll cover unit testing.

Unit testing is one of the most popular and important types of testing because it is focused on testing individual units of code in isolation.

Here are some reasons why we unit test:

  • Faster feedback loop: Unit tests are faster to run than other types of tests because they only test a small unit of code. This means that you can get feedback on your code more quickly, which makes it easier to identify and fix bugs.
  • Easier to write: Unit tests are easier to write because they focus on a small unit of code, such as a function or method. This makes it easier to write tests that are specific and targeted.
  • More granular control: Unit tests allow you to test individual units of code in isolation, which gives you more granular control over how your code is tested. This means that you can test edge cases and unusual scenarios more easily.
  • Better coverage: Because unit tests focus on individual units of code, they can provide better coverage.
  • Easier to refactor: Unit tests can make it easier to refactor your code because they ensure that your code still works as intended after changes are made. By having a good set of unit tests, you can refactor your code with confidence, knowing that you have a safety net to catch bugs and ensure that your code still works as intended.

Language Constructs

When writing unit tests, there are several commonly used language constructs or functions that help to organize and define the structure of the tests. Here are some examples:

assert or expect: This is a function that is used to check if a particular condition is true. For example, if you are testing a function that should return a certain value, you can use the assert function to check if the actual return value matches the expected value.

beforeEach or setup: These are functions that are executed before each test case. They are typically used to set up the test environment or create any necessary objects or data structures that the tests will use. For example, if you are testing a function that depends on a database connection, you might use the beforeEach function to establish a connection to the database.

afterEach or teardown: These are functions that are executed after each test case. They are typically used to clean up any resources that were created during the test. For example, if you created a temporary file during a test, you might use the afterEach function to delete the file.

describe: This function is used to group related tests together. For example, if you are testing a particular class, you might use the describe function to group all the tests for that class in a single block.

it or test: This function is used to define an individual test case. The it function typically takes a description of what the test is checking and a function that contains the actual test code.

These constructs are typically used in testing frameworks such as Mocha, Jasmine, or Jest, and they provide a standard way to write and organise unit tests. By using these constructs consistently, you can create a readable and maintainable suite of tests that help to ensure the quality of your code.

Here is an example of a unit test:

describe('SearchController', () => {
let searchController: SearchController;

beforeEach(() => {
searchController = new SearchController();
});

afterEach(() => {
searchController = null;
});

test('returns expected results', () => {
const results = searchController.search("iPhone");
expect(results.length).toEqual(10);
expect(results).toContain('iPhone 13');
expect(results).toContain('iPhone 12');
expect(results).toContain('iPhone SE');
});
});

class SearchController {
search(query: string): string[] {
// Code to perform search and return results
return [
'iPhone 13',
'iPhone 12',
'iPhone SE',
'iPhone 11',
'iPhone XS',
'iPhone XR',
'iPhone X',
'iPhone 8',
'iPhone 7',
'iPhone SE 2020',
];
}
}

In this example, we’re testing a SearchController class that performs a search based on a user’s query. Here’s how we’re using AAA testing to structure the test:

  • The describe function groups tests together. In this case, we’re describing the behaviour of the SearchController.
  • The beforeEach function sets up the test environment by creating a new instance of SearchController before each test.
  • The afterEach function cleans up our test environment by setting searchController to null after each test.
  • Defining a new test case by using the test function. In this test case, we’re checking that searchController.search() returns the expected results.

Just to clarify, the example provided here uses the Jest testing framework.

AAA Testing

AAA testing is a style of unit testing that emphasises three important aspects of a unit test: Arrange, Act, and Assert. The idea behind AAA testing is to structure your unit tests in a consistent and organised way that makes it easy to understand what the test is doing and what it’s checking.

Here’s what each of the three A’s represents in AAA testing:

Arrange: This is the first step in a unit test, and it involves setting up the test environment. This might involve creating objects or data structures that the test will use, or configuring any dependencies that the code being tested relies on.

Act: This is the second step in a unit test, and it involves performing the actual test. This might involve calling a method or function with specific input parameters, or interacting with an object in a specific way.

Assert: This is the final step in a unit test, and it involves checking that the test has produced the expected result. This might involve checking the return value of a function, or checking that a particular object has been updated in the expected way.

By using AAA testing, you can create unit tests that are easy to read and understand, even for developers who are not familiar with the code being tested. It also helps to ensure that your tests are consistent and maintainable.

Here is an example using XCode and Swift:

import XCTest

class SearchTests: XCTestCase {

var searchController: SearchController!

override func setUp() {
super.setUp()
searchController = SearchController()
}

override func tearDown() {
searchController = nil
super.tearDown()
}

func testSearchReturnsExpectedResults() {
// Arrange
let query = "iPhone"

// Act
let results = searchController.search(query: query)

// Assert
XCTAssertEqual(results.count, 10)
XCTAssertTrue(results.contains("iPhone 13"))
XCTAssertTrue(results.contains("iPhone 12"))
XCTAssertTrue(results.contains("iPhone SE"))
}
}

class SearchController {

func search(query: String) -> [String] {
// Code to perform search and return results
return ["iPhone 13", "iPhone 12", "iPhone SE", "iPhone 11", "iPhone XS", "iPhone XR", "iPhone X", "iPhone 8", "iPhone 7", "iPhone SE 2020"]
}

}

In the Arrange section, we set the query variable to “iPhone”, which is the query we want to search for.

In the Act section, we call searchController.search(query: query) to perform the search and get the results.

In the Assert section, we use XCTAssertEqual() to check that the number of results returned is equal to 10, and we use XCTAssertTrue() to check that specific results are included in the results array.

By structuring our test in this way, we can more easily see what the test is doing, and what it’s checking. This can make it easier to write, read, and maintain unit tests over time, and can help to ensure the quality and reliability of our e-commerce application.

Let’s do the same for a Kotlin example:

import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test

class SearchTests {

private var searchController: SearchController? = null

@Before
fun setUp() {
searchController = SearchController()
}

@After
fun tearDown() {
searchController = null
}

@Test
fun testSearchReturnsExpectedResults() {
// Arrange
val query = "Samsung"

// Act
val results = searchController?.search(query)

// Assert
assertNotNull(results)
assertEquals(results?.size, 5)
assertTrue(results?.contains("Samsung Galaxy S21") ?: false)
assertTrue(results?.contains("Samsung Galaxy S20") ?: false)
assertTrue(results?.contains("Samsung Galaxy Note20") ?: false)
assertTrue(results?.contains("Samsung Galaxy A71") ?: false)
assertTrue(results?.contains("Samsung Galaxy A51") ?: false)
}
}

class SearchController {

fun search(query: String): List<String> {
// Code to perform search and return results
return listOf(
"Samsung Galaxy S21",
"Samsung Galaxy S20",
"Samsung Galaxy Note20",
"Samsung Galaxy A71",
"Samsung Galaxy A51"
).filter { it.contains(query, ignoreCase = true) }
}

}

Naming conventions

Naming our tests and test suites is an important part of writing effective unit tests. A good naming convention can make it easier to understand what a test is doing, and can help to ensure that our tests are clear, concise, and maintainable over time. Here are some best practices to keep in mind when naming your tests and test suites:

  • Use descriptive names: Your test names should describe what the test is doing in a clear and concise way. Avoid using generic names like “test1” or “test2”, as these can be difficult to understand and maintain.
  • Use sentence case: Use sentence case to make your test names more readable. This means that the first word should be capitalized, and the rest of the words should be in lowercase, with the exception of proper nouns.
  • Use underscores or spaces to separate words: Use underscores or spaces to separate words in your test names. This makes them easier to read, especially when the names are long.
  • Use context in your test suite names: Your test suite names should describe the context in which the tests are being run. For example, if you’re testing a specific class or function, include the name of that class or function in the test suite name.
  • Use the “should” convention: Use the “should” convention in your test names to describe what the code being tested should do. For example, “should return true when the input is valid”.
  • Be consistent: Use a consistent naming convention throughout your codebase. This makes it easier to understand and maintain your tests over time, and ensures that your code is easy to read and understand for other developers.

By following these best practices, you can ensure that your tests and test suites are clear, concise, and easy to understand, making it easier to write, read, and maintain your unit tests over time.

In our TypeScript example, let’s update the name of our test:

 test('should return expected results for Samsung phones', () => {
// Arrange
const query = 'Samsung';

// Act
const results = searchController.search(query);

// Assert
expect(results.length).toEqual(5);
expect(results).toContain('Samsung Galaxy S21');
expect(results).toContain('Samsung Galaxy S20');
expect(results).toContain('Samsung Galaxy Note20');
expect(results).toContain('Samsung Galaxy A71');
expect(results).toContain('Samsung Galaxy A51');
});

In this updated example, we’ve used the following naming conventions:

  • We’ve used a descriptive name for the test suite: ‘SearchController’. This makes it clear what the tests in this suite are testing.
  • We’ve used the “should” convention in the test name: ‘should return expected results for Samsung phones’. This describes what the code being tested should do.
  • We’ve used single quotes to surround the test name, which makes it more readable and distinguishes it from regular code.

By using good naming conventions, we can make our tests more readable and maintainable, and help other developers understand what our code is doing. This can improve the quality and reliability of our code, and help to ensure that our e-commerce application works as expected.

Mocking

Mocking is an important part of unit testing because it allows us to isolate and test individual units of code, even if they have dependencies on other parts of the system. When we mock a dependency, we replace it with a “dummy” object that simulates the behavior of the real object, but without the complexity or overhead of the real object.

Mocking is particularly useful when we need to test code that interacts with external systems or services, such as a database, API, or file system. By mocking these external dependencies, we can test our code in a controlled environment, without worrying about issues such as network connectivity, slow response times, or data integrity.

In addition to simplifying testing, mocking also makes it easier to write and maintain tests. By breaking our code down into smaller, more isolated units, we can test each unit separately, and make changes to the code without affecting other parts of the system. This can save time and effort, and help to ensure that our tests remain accurate and reliable over time.

Here’s an example of writing a unit test with mocks in TypeScript

import { UserService } from './user.service';
import { NotificationService } from './notification.service';

describe('UserService', () => {
let userService: UserService;
let notificationServiceMock: NotificationService;

beforeEach(() => {
notificationServiceMock = {
sendEmail: jest.fn(),
sendPushNotification: jest.fn(),
sendSMS: jest.fn(),
};
userService = new UserService(notificationServiceMock);
});

test('createUser should call notificationService.sendEmail', () => {
// Arrange
const user = {
name: 'John Doe',
email: 'johndoe@example.com',
};

// Act
userService.createUser(user);

// Assert
expect(notificationServiceMock.sendEmail).toHaveBeenCalled();
});
});

In this example, we’re testing a UserService class that depends on a NotificationService class. Rather than using the real NotificationService object, we’ve created a mock object using the jest.fn() function.

We’ve then passed the mock object to the UserService constructor, and used it to test the createUser method. By doing this, we can test the behaviour of the createUser method in isolation, without worrying about the behaviour of the NotificationService.

In the test itself, we’re arranging the test environment by creating a user object, acting on the code being tested by calling createUser, and asserting that the sendEmail method of the mock NotificationService object has been called.

By using mocks in our unit tests, we can isolate individual units of code and test them in a controlled environment, which makes it easier to write, read, and maintain our tests over time. This can improve the reliability and quality of our software, and help to catch bugs early in the development process.

Conclusion

In conclusion, writing effective unit tests is an essential part of software development, and it requires a combination of language constructs, AAA testing, and naming conventions to achieve the best results.

When writing unit tests, it’s important to use appropriate language constructs, such as assertions, setup and teardown functions, and other tools that are available in the programming language and testing framework. This helps to ensure that our tests are accurate and reliable, and that they cover the different scenarios and use cases that our software might encounter.

AAA testing is a useful technique for organising unit tests, as it provides a clear and concise structure for arranging the test environment, acting on the code being tested, and asserting that the result is as expected. By following this structure, we can create tests that are easy to read, write, and maintain, and that provide maximum coverage for our code.

Naming conventions are also an important part of writing effective unit tests. Using descriptive and consistent names for our tests and test suites helps to ensure that our code is readable and understandable for other developers, and that our tests are clear, concise, and easy to maintain.

By combining these different techniques, we can write high-quality unit tests that catch bugs early, improve the reliability and quality of our code, and help to ensure that our software works as expected. With careful planning and attention to detail, we can make unit testing an integral part of our software development process, and achieve the best possible results for our e-commerce application, or any other software project.

--

--