A unit test usually involves writing code to test a specific function or method, using input data and expected output data. The test code then executes the function or method with the input data, compares the resulting output to the expected output, and reports any discrepancies as errors or failures. This approach allows developers to test each unit of code in isolation, ensuring that their code is modular, well-designed, and functioning as intended.
For example, if you have a method that converts a full name to a first name and a last name and the input is John Doe
, what you would want to validate is that the output of that method is John
and Doe
. If that breaks, then your assertion breaks, and you would need to fix your code. That’s the main idea behind any type of testing.
Unit testing is testing what is called the “unit”. A “unit” can be anything, but, usually, unit tests assess the method.
In unit testing any input and output (I/O) calls are mocked. I/O calls include:
The main idea behind mocking in unit testing is to avoid making calls to external components, such as APIs or databases, during testing. These calls can slow down unit tests, which should ideally be fast. Instead of setting up a whole environment to make these calls, developers can use mocks to simulate their behavior. Mocks replace the actual calls with simulated responses, allowing developers to test their code without relying on external components that may not always be available or consistent.
The short execution time feedback loop, mocking dependencies and then getting a quick response on how your system performs is extremely powerful. It can catch bugs before they make it near production.
Isn't it enough to test the system manually instead of relying on automated unit testing?
If you do this every single time, you can’t possibly test every single scenario, every single time you make a code change, manually. You need an automated way to do that. A unit test which is fast and small. If a bug gets deployed into production and is found by a customer, then it would cost your company a lot of money, even more compared to the money spent for developers to write those unit tests in the first place.
Another advantage of unit tests is that they can be read as documentation. You can give very descriptive names in your unit tests and then you can just read the method names of the unit tests and understand what your application is doing.
They also “force” the developers to write better and cleaner code because unit testing needs some very specific techniques to be good, like SOLID principles, Inversion of Control, and Dependency Injection.
In most companies, unit tests are mandatory. They are run as part of the build pipeline and if at least one unit test fails, the whole build fails. You need to have a certain number of tests before you can push anything into production, thus proving that your application code is covered.
Finally, some people think unit testing is so important, that they choose to do what’s called “Test Driven Development” (TDD). TDD is a practice where you write your tests before you write your actual code.
In unit testing, you have three core concepts:
To apply the concepts presented in this article, a sample API will be used. This API was developed using the HTTP-Trigger based Azure Function Template (C#) in VS2022. It contains a single POST
endpoint, which you can call to create a Note. As part of this create note operation, the note is being persisted in Cosmos DB and then a simple noteCreated
event is sent to a third party notification system.
The structure of the solution follows this format:
The src
solution folder contains the source code of the Azure Functions API project (“Fta.DemoFunc.Api”) and the tests
solution folder contains the two test projects, one for Unit (Fta.DemoFunc.Api.Tests.Unit
) and the other for Integration (Fta.DemoFunc.Api.Tests.Integration
) tests respectively.
To be easier to write unit tests, the dependency injection (DI) software design pattern is being applied in the Azure Functions project, which is a technique to achieve Inversion of Control (IoC) between classes and their dependencies. Below you can see the Startup.cs
file, containing the setup and configuration of the DI container.
The Azure Functions project contains a single Function called NotesFunction
. The “business logic” of the Azure Function has been extracted into a service called NoteService
, which implements the interface called INoteService
.
As you can see in the image above the NotesFunction
contains a single POST
endpoint which accepts a CreateNoteRequest
object from the client and delegates the note creation process to the service that implements the INoteService
interface. As DI is applied to this class, now it is easy to write unit tests against it. The process you follow is the same as you would write unit tests for any other class.
As mentioned above, there are three core unit testing concepts: The Testing, the Mocking, and the Assertion Library. In this demo project, xUnit is used for the Testing Library, NSubstitute for the Mocking Library and Fluent Assertions for the Assertion Library.
To begin writing unit tests for the NotesFunction
class, a new class called NotesFunctionTests
is created inside the Fta.DemoFunc.Api.Tests.Unit
project.
Based on the above implementation, we need to write three unit tests to cover all scenarios for the POST
method of our “System Under Test - SUT” (NotesFunction
).
The first unit test will cover the scenario, where the NoteService
is called and completed successfully, so our function will return a CreatedResult
(201
status code) to the client along with the details of the created note.
The second unit test will cover the scenario, where the NoteService
returns null
, so our function will return a BadRequestObjectResult
(400
status code) to the client along with an error message.
Finally, the third unit test will cover the scenario of an exception being thrown from the NoteService
. In that case, we are going to log the error and just return an InternalServerErrorResult
(500
status code) back to the client.
As you can see above, all unit tests have very descriptive names and read like documentation. Also, each unit test follows the “AAA” pattern. “AAA” stands for “Arrange”, “Act” and “Assert”. In the “Arrange” part, you write initialization code for your unit test, in the “Act” part you call the method of your SUT you write your unit test against and in the “Assert” part you make your assertions, based on what is expected as the output of the unit test.
All code, examples and details for this project can be found in this GitHub repo.
Integration testing is the phase in software testing in which individual software modules are combined and evaluated as a group. It is conducted to evaluate the compliance of a system or component with specific functional requirements. It occurs after unit testing and before end-to-end testing.
Integration tests usually check for what we call the happy and unhappy path. There is also value in calling the database or API dependencies to make sure that they are behaving correctly. This is where integration tests come into the picture because they will call dependencies. This means that either you need to have a working and running environment for this, or you need to spin up one on demand (e.g., in docker). They are also bigger in scope compared to unit tests.
If you are going through the full testing flow and you have your unit tests first, integration tests give you a better idea of how your system will perform when integrating with other components. You assume quite a lot in unit testing and that is not great when you want a realistic representation of your system.
Let’s now examine what exactly is the scope of integration testing when it comes to things you are calling or mocking (i.e., file system, network calls, database calls). For example, let’s say you have an API that makes a few calls. One of them is to the GitHub API, another is to an internal API that you own, another is in the database and the final one is in the file system.
In the context of an integration test, the call to the database must happen, so you would use a realistic database and you would not replace that with anything “mocked”. The call to the file system must also happen.
The call to another API that your API needs to work with has some additional considerations to think about. If this is an API that you own (i.e., another API in your system), then you get to choose whether to run and call it or just mock it. On the other hand, if this is an external API that you do not own (e.g., the GitHub API), because you do not have any control over that, this is out of scope for your integration tests. Instead, you would replace that with an API that accepts requests and responds as if it was the GitHub API, ensuring that there is still some integration point with the same contracts / HTTP headers / models, etc. You can also choose to mock it, like you do in the unit testing way.
Let’s examine how to apply the above concepts in the context of the example Azure Functions HTTP-Trigger based “Notes” API. To begin writing integration tests for the NotesFunction
class, a new class called NotesFunctionTests
is created inside the Fta.DemoFunc.Api.Tests.Integration
project.
In this example, we have two dependencies in our code: The Cosmos DB component where notes are persisted and the 3rd party API notification system. For the Cosmos DB case, as this is something that you own and control, you could spin up this dependency locally, if possible. You could choose to run your tests against a local Cosmos DB instance instead of a remote one. This has couple advantages:
In our example, we will run our integration tests against a local instance of Azure CosmosDB, using the Azure Cosmos DB Emulator.
The crucial part of our integration test setup is to configure dependency injection. You need the following classes for the setup:
TestStartup.cs
class will be introduced and will derive from the Azure Function’s Startup
class to define dependency injection for our test.
local.settings.json
to store configuration, which will never leave your local machine.
TestsInitializer.cs
) for our integration test using TestStartup
.
IntegrationTestsCollection.cs
) by deriving from ICollectionFixture
class.
After setting up all the above components, we can continue with creating our integration tests. Based on the implementation of our NotesFunction
class, we need to write two integration tests to cover the “happy” and the “unhappy” path for the POST
method of our “System Under Test - SUT” (NotesFunction
).
The first integration test will cover the “happy path” scenario, where the POST
endpoint is called with valid note details, so our function will return a CreatedResult
(201
status code) to the client along with the details of the created note.
The second unit test will cover the scenario, where the POST
endpoint is called with invalid note details, so our function will return a BadRequestObjectResult
(400
status code) to the client along with some error message.
As you can see above, all integration tests have very descriptive names and read like documentation. Also, each integration test follows the “AAA” pattern, just like unit tests.
All code, examples and details for this project can be found in this GitHub repo.
The “testing pyramid” is a visualization technique to see how important the distinct types of testing are and how much of it you need in a project. The pyramid has 3 levels:
Unit tests are the larger number of tests you are going to have in your code, to cover any scenario that you need to validate against. Integration tests are a bit higher than unit tests, and you have less compared to unit tests, because you are testing a broader scenario in your application. Finally, end to end tests are the highest and the least because you are only evaluating the few things your application exposes.
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.