Hello everyone, today I would like to talk to you about a crucial phase during the development of a software application, framework, or product: Testing.
Implementing automated tests is essential to ensure the quality of our projects and to prevent any changes to our code from introducing unpleasant regressions.
We have at our disposal many types of unit tests, integration tests, and end-to-end (E2E) tests. In this article, we will examine the first two on this list and also mention a series of tools that can greatly assist you in creating your tests.
Table of Contents:
Test Pyramid
The Test Pyramid is a concept that visualizes different types of automated tests by categorizing them based on their complexity and execution speed. The higher a test is on the pyramid, the slower it will be in overall execution.
As you can see, starting from the bottom, we have Unit Tests at the base of the pyramid. They ensure that an isolated unit of code functions as it should and are the fastest tests in the pyramid.
At the second level of the pyramid, we encounter Integration Tests. These tests are used to verify if components interact correctly with each other to satisfy business rules. The main objective of these tests is to ensure that the involved components communicate without errors when combined.
These tests are much slower compared to Unit Tests and are typically used and triggered by a Continuous Integration (CI) system to ensure that the code being integrated does not conflict with other parts of the code.
At the top of our pyramid, we have End-to-End (E2E) tests, which verify the correct functioning of an application from the initial point to the final one, simulating a complete user journey. It is, of course, the slowest type of test in our pyramid.
Nunit for Unit Tests
For this article, we will be using the NUnit library in the context of .NET Core. The reason I choose to use it is because I prefer it over XUnit (another testing library in the .NET context). NUnit has specific attributes to manage the test lifecycle, making our tests clear and clean (of course, like all things in software development, this is my opinion and perspective).
To provide an overview of the test lifecycle, we need to talk about the de facto standard for developing a test, which is the 3A paradigm (Arrange, Act, Assert). When developing a test, the test method follows these three common phases:
- Arrange: This is the phase in which we create all the objects we need to test classes, methods, or services that are under test.
- Act: It invokes the class, method, or service being tested.
- Assert: In this phase, we verify that the test has responded correctly using the Assert class's methods in NUnit (or in any testing library you are using).
Remember that as a good testing practice, it's essential to test only one element at a time, be it a class, a method, or a service.
Let's go through a small example of a Unit Test:
using NUnit.Framework;
public class Calculator
{
public int Sum(int a, int b)
{
return a + b;
}
}
[TestFixture]
public class CalculatorTests
{
[Test]
public void TestSum()
{
// Arrange (Preparation)
Calculator calculator = new Calculator();
int number1 = 5;
int number2 = 7;
// Act (execution)
int result = calcolatrice.Sum(number1, number2);
// Assert (check)
Assert.AreEqual(12, result);
}
}
Now, starting from the base of this example, we can see that in the Arrange phase, we instantiated the Calculator class to subject the Sum() method to a test. However, the instance of that class will be available only in the TestSum() test method. But what if we had more methods to test?
If we had a Multiply method to test, we would have to repeat the entire arrangement process. This is where the NUnit lifecycle attributes I mentioned at the beginning of the session come into play. In this article, we'll cover four of them:
- OneTimeSetup: It activates only once at the beginning of all the tests in a class.
- SetUp: It activates just before the start of each test method.
- OneTimeTearDown: It activates only once at the end of all the tests in a class.
- TearDown: It activates immediately after the end of each test method.
Returning to the previous example:
//file Calculator.cs
public class Calculator
{
public int Sum(int a, int b)
{
return a + b;
}
public int Multiply(int a, int b)
{
return a * b;
}
}
// file CalculatorTests.cs
using NUnit.Framework;
[TestFixture]
public class CalculatorTests
{
private Calcolatrice calculator;
private int number1;
private int number2;
[OneTimeSetUp]
public void OneTimeSetup()
{
// Inizializzazione comune a tutti i test della classe
calculator = new Calculator();
numero1 = 5;
numero2 = 7;
}
[Test]
public void TestSum()
{
// Act (execution)
int result = calculator.Sum(number1, number2);
// Assert (check)
Assert.AreEqual(12, result);
}
[Test]
public void TestMultiply()
{
// Act (execution)
int result = calculator.Multiply(number1, number1);
// Assert (check)
Assert.AreEqual(35, result);
}
}
As you've seen in this example, we've centralized the creation of the instance of the Calculator class, making it available throughout the test class. The reason for preferring NUnit over XUnit (unless, of course, I'm required to use it for work-related reasons) is precisely the cleanliness that comes from these practices. If I needed to inject some dependencies into the test class constructor now, the Arrange phase would remain isolated, keeping the two phases distinct.
One very useful element I want to draw your attention to is the TestCase attribute, which allows us to define multiple test scenarios by retrieving values as if they were parameters passed to the test method:
// file Calculator.cs
public class Calculator
{
public int Sum(int a, int b)
{
return a + b;
}
public int Multiply(int a, int b)
{
return a * b;
}
}
// file CalculatorTests.cs
using NUnit.Framework;
[TestFixture]
public class CalculatorTests
{
private Calcolatrice calculator;
private int number1;
private int number2;
[OneTimeSetUp]
public void OneTimeSetup()
{
// Inizializzazione comune a tutti i test della classe
calculator = new Calculator();
numero1 = 5;
numero2 = 7;
}
[Test]
public void TestSum()
{
// Act (execution)
int result = calculator.Sum(number1, number2);
// Assert (check)
Assert.AreEqual(12, result);
}
[Test]
public void TestMultiply()
{
// Act (execution)
int result = calculator.Multiply(number1, number1);
// Assert (check)
Assert.AreEqual(35, result);
}
[Test]
[TestCase(2, 3, 6)]
[TestCase(4, 6, 24)]
[TestCase(0, 5, 0)]
public void TestMultiplyWithMultipleCases(int a, int b, int expectedResult)
{
// Act (execution)
int result = calculator.Multiply(a, b);
// Assert (check)
Assert.AreEqual(expectedResult, result);
}
}
In this example, you can see that thanks to this, we have three test cases for the TestMultiplyWithMultipleCases method, where the first number in the TestCase attribute corresponds to the 'a' parameter, the second to 'b', and the third to 'expectedResult'.
In the last tests we executed, we always used scalar values as both parameters and results, and the methods under test had no internal dependencies. This made our testing much easier. But what if we had methods with dependencies?
Let's start by understanding what a dependency is. We refer to a dependency as a class, property, method, or object linked to another class, property, method, or object and essential for its proper functioning. For example, in the Calculator class:
public class CalculatorScientific
{
private ICalculator calculatorBase; // Calculator Dependence
public CalculatorScientific(ICalculator calculator)
{
calculatorBase = calculator; // We initialize the dependency
}
public double Pow(int baseNumber, int sponent)
{
// We use the Basic Calculator to perform multiplication
if (sponent== 0)
{
return 1;
}
else if (sponent> 0)
{
double result = 1;
for (int i = 0; i < sponent; i++)
{
result = calculatorBase.Multiply(result, baseNumber);
}
return result;
}
else
{
throw new ArgumentException("The exponent must be a nonnegative number.");
}
}
// More advanced methods here
}
Now, as you can see, the ICalculator interface has become a dependency of the CalculatorScientific class. So how do we test this class? Can we simply insert a Calculator object during the creation of an instance of CalculatorScientific? ****
using NUnit.Framework;
[TestFixture]
public class CalculatorScientificTests
{
private CalculatorScientific calculatorScientific;
[OneTimeSetUp]
public void OneTimeSetup()
{
Calculator calculatorBase = new Calculator();
calculatorScientific = new CalculatorScientific(calculatorBase);
}
[Test]
public void TestPow()
{
// Act
double risultato = calcolatriceScientifica.Pow(2, 3);
// Assert
Assert.AreEqual(8, risultato);
}
}
Now, this may not be the best approach because it creates a tight coupling between Calculator and CalculatorScientific, making it difficult to change the implementation of Calculator in the future or use a different class. This would require significant changes to CalculatorScientific.
Secondly, injecting real dependencies during a test might require access to external resources or behave unpredictably, like database connections and web services.
This is where Moq comes into play. It's a fantastic library that allows us to mock dependencies during the testing phase, isolating them and creating simulated implementations. Thanks to this library, we can have complete control over the configuration of dependency behaviors, making them return specific values or even generate exceptions.
In light of all this, let's rewrite our test:
using Moq;
using NUnit.Framework;
[TestFixture]
public class CalculatorScientificTests
{
private CalculatorScientific calculatorScientific;
private Mock<Calculator> mockCalculator;
[OneTimeSetUp]
public void OneTimeSetup()
{
mockCalculator = new Mock<Calculator>();
calculatorScientific = new CalculatorScientific(mockCalcolatrice.Object);
}
[Test]
public void TestPotenza()
{
// Arrange
mockCalculator.Setup(x => x.Multiply(It.IsAny<int>(), It.IsAny<int>())).Returns((int a, int b) => a * b);
// Act
double result = calculatorScientific.Pow(2, 3);
// Assert
Assert.AreEqual(8, result);
}
[Test]
public void TestPotenza_ConEsponenteNegativo_DeveLanciareEccezione()
{
// Arrange
mockCalculator.Setup(x => x.Multiply(It.IsAny<int>(), It.IsAny<int>())).Returns((int a, int b) => a * b);
// Act & Assert
Assert.Throws<ArgumentException>(() => calculatorScientific.Potenza(2, -3));
}
}
In this example, we used Moq to create a mock of the Calculator class and configured its behavior using the Setup method to enable testing of the Pow method. The second method, on the other hand, checks that an ArgumentException is thrown when a negative exponent is used.
NUnit for Integration Test
Now that we have extensively covered the part related to unit tests, we can focus on the second level of our pyramid, which is integration tests.
As mentioned earlier, this type of test is done to ensure the overall application's proper functioning, evaluating how different parts of the software interact and communicate in a more realistic environment, involving, for example, databases or external web services.
For this example, we will test a repository that queries data using Entity Framework Core. If you're not familiar with how to configure Entity Framework, here's a link to a guide I wrote: The comprehensive guide to Entity Framework Core.
Let's create our entity:
public class Product
{
public int ProductId { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
Now let's define a repository for data access:
public interface IProductRepository
{
Product GetById(int productId);
void Add(Product product);
void SaveChanges();
}
public class ProductRepository : IProductRepository
{
private ProductContext context;
public ProductRepository(ProductContext context)
{
this.context = context;
}
public Product GetById(int productId)
{
return context.Products.Find(productId);
}
public void Add(Product product)
{
context.Products.Add(product);
}
public void SaveChanges()
{
context.SaveChanges();
}
}
And let's create the test for our ProductIntegrationTests class:
using System.Data.Entity;
using System.Linq;
using Moq;
using NUnit.Framework;
public class ProductIntegrationTests
{
[Test]
public void AddProductAndRetrieveFromDatabase()
{
// Arrange
var product = new Product(){
Name = "Test Product",
Price = 10.00
};
var dbContextOptions = new DbContextOptionsBuilder<ProductContext>()
.UseInMemoryDatabase(databaseName: "InMemoryDatabase")
.Options;
using (var context = new ProductContext(dbContextOptions))
{
var productRepository = new ProductRepository(context);
productRepository.Add(product);
productRepository.SaveChanges();
}
using (var context = new ProductContext(dbContextOptions))
{
var productRepository = new ProductRepository(context);
// Act
var retrievedProduct = productRepository.GetById(product.ProductId);
// Assert
Assert.IsNotNull(retrievedProduct);
Assert.AreEqual(product.ProductId, retrievedProduct.ProductId);
Assert.AreEqual(product.Name, retrievedProduct.Name);
Assert.AreEqual(product.Price, retrievedProduct.Price);
}
}
}
In this example, we created a Product object, configured an InMemoryDatabase to simulate a database using Entity Framework Core, added the product to the database, and then retrieved it using the GetById method in the Act phase. We then created standard assertions, as we would in a typical Unit Test.
One last small piece of advice I would like to give you when performing integration tests is not to manually create your entity classes. Our example referred to a very small class with only two fields, but if you have much more complex objects, the task can become tedious and, moreover, it wouldn't allow us to focus on our true goal, which is testing.
For this purpose, I use a library called AutoFixture, which allows us to create our test objects easily. It also lets us configure our objects using a builder, indicating which properties to create, which ones not to, the format, custom values, and more.
If we had used AutoFixture, its implementation would look like this:
using System.Data.Entity;
using System.Linq;
using AutoFixture;
using Moq;
using NUnit.Framework;
public class ProductIntegrationTests
{
[Test]
public void AddProductAndRetrieveFromDatabase()
{
// Arrange
var fixture = new Fixture();
var product = fixture.Create<Product>();
// other instructions...
}
}
That's it! Isn't it fantastic?
Now, we've reached the end of this long article. I hope you enjoyed it and found it useful. If you did, please give it a like and share it as much as you can!
As always, I'd love to hear your suggestions and feedback to improve and delve deeper into these topics.
Happy Coding!
Top comments (0)