Unit Testing vs. Integration Testing
Mind Map Summary
- Goal: Ensure software quality and correctness.
- The Testing Pyramid: A strategy that suggests writing more low-level tests (unit) and fewer high-level tests (E2E).
- 1. Unit Tests (Base of the Pyramid)
- What: Test the smallest testable parts of an application in isolation (e.g., a single method, a single class).
- Characteristics:
- Fast: Execute in milliseconds.
- Cheap: Easy to write and maintain.
- Isolated: Dependencies are replaced with mocks or stubs.
- Purpose: Verify the correctness of individual units of code.
- 2. Integration Tests (Middle of the Pyramid)
- What: Test how different units or modules interact with each other.
- Characteristics:
- Slower: Involve more components, potentially real databases or external services.
- More Expensive: Harder to write and maintain than unit tests.
- Dependencies: Often use real dependencies or in-memory versions of them.
- Purpose: Verify the correct flow and interaction between integrated components.
- 3. End-to-End (E2E) Tests (Top of the Pyramid)
- What: Test the entire application flow from a user’s perspective, simulating real user interactions.
- Characteristics:
- Slowest: Can take seconds or minutes to run.
- Most Expensive: Hardest to write, maintain, and debug.
- Dependencies: Requires the full application stack to be running.
- Purpose: Verify the entire system works as expected from a user’s point of view.
- 1. Unit Tests (Base of the Pyramid)
Core Concepts
1. Unit Testing
Unit tests are the bedrock of a good testing strategy. They focus on testing individual units of code in isolation. A “unit” is typically the smallest testable part of an application, often a single method or a class. The key to unit testing is isolation. If your unit has dependencies (e.g., a service that depends on a database repository), those dependencies are replaced with mocks or stubs. This ensures that if a test fails, you know exactly which unit of code is responsible for the failure, and the test runs extremely fast because it doesn’t hit a real database or external service.
2. Integration Testing
Integration tests verify that different parts of your application work correctly when put together. For example, you might test if your service layer correctly interacts with your database repository, or if your API controller correctly calls your service and returns the expected result. Unlike unit tests, integration tests often use real (or near-real) dependencies. For example, you might use an in-memory database for testing data access, or a test HTTP server for testing API calls. Integration tests are slower than unit tests but provide higher confidence that your components work together as intended.
Practice Exercise
Given a service class that has a dependency on a repository interface (e.g., IProductRepository
), write a unit test for a method in the service. Use a mocking framework like Moq or NSubstitute to create a mock of the repository and isolate the service for testing.
Answer
We will use Moq as the mocking framework for this exercise.
1. The Service and Interface
// IProductRepository.cs
public interface IProductRepository
{
Product GetById(int id);
void Add(Product product);
}
// Product.cs (simple POCO)
public class Product { public int Id { get; set; } public string Name { get; set; } }
// ProductService.cs (the class we want to unit test)
public class ProductService
{
private readonly IProductRepository _productRepository;
public ProductService(IProductRepository productRepository)
{
_productRepository = productRepository;
}
public Product GetProductDetails(int productId)
{
// Business logic: maybe add some logging or validation
if (productId <= 0) throw new ArgumentException("Product ID must be positive.");
var product = _productRepository.GetById(productId);
if (product == null)
{
// Maybe throw a custom exception or return null based on business rules
return null;
}
// Further business logic could be applied here
return product;
}
}
2. The Unit Test (ProductServiceTests.cs
)
First, install Moq: dotnet add package Moq
using Moq;
using Xunit; // Or NUnit, MSTest
public class ProductServiceTests
{
[Fact]
public void GetProductDetails_ShouldReturnProduct_WhenProductExists()
{
// Arrange
var productId = 1;
var expectedProduct = new Product { Id = productId, Name = "Test Product" };
// Create a mock of the dependency (IProductRepository)
var mockRepository = new Mock<IProductRepository>();
// Configure the mock: When GetById is called with productId, return expectedProduct
mockRepository.Setup(repo => repo.GetById(productId))
.Returns(expectedProduct);
// Create an instance of the service (the Unit Under Test), injecting the mock
var productService = new ProductService(mockRepository.Object);
// Act
var result = productService.GetProductDetails(productId);
// Assert
// Verify that the service returned the expected product
Assert.NotNull(result);
Assert.Equal(expectedProduct.Id, result.Id);
Assert.Equal(expectedProduct.Name, result.Name);
// Verify that the GetById method on the mock was called exactly once with the correct ID
mockRepository.Verify(repo => repo.GetById(productId), Times.Once);
}
[Fact]
public void GetProductDetails_ShouldReturnNull_WhenProductDoesNotExist()
{
// Arrange
var productId = 99;
var mockRepository = new Mock<IProductRepository>();
// Configure the mock: When GetById is called, return null
mockRepository.Setup(repo => repo.GetById(It.IsAny<int>()))
.Returns((Product)null); // Explicitly cast to Product
var productService = new ProductService(mockRepository.Object);
// Act
var result = productService.GetProductDetails(productId);
// Assert
Assert.Null(result);
mockRepository.Verify(repo => repo.GetById(productId), Times.Once);
}
[Fact]
public void GetProductDetails_ShouldThrowArgumentException_WhenProductIdIsInvalid()
{
// Arrange
var productId = 0; // Invalid ID
var mockRepository = new Mock<IProductRepository>(); // Mock is still needed for constructor
var productService = new ProductService(mockRepository.Object);
// Act & Assert
// Use Assert.Throws to verify that an exception is thrown
Assert.Throws<ArgumentException>(
() => productService.GetProductDetails(productId)
);
// Verify that the repository method was NOT called
mockRepository.Verify(repo => repo.GetById(It.IsAny<int>()), Times.Never);
}
}
Explanation
- Isolation: The
ProductService
is the “unit under test.” Its dependency,IProductRepository
, is replaced by a mock object created by Moq (mockRepository.Object
). This means our test forProductService
does not actually interact with a real database. It only verifies thatProductService
behaves correctly based on what it expects from itsIProductRepository
. mockRepository.Setup(...)
: This line tells the mock what to do when a specific method is called on it. For example,mockRepository.Setup(repo => repo.GetById(productId)).Returns(expectedProduct);
means “WhenGetById
is called withproductId
, returnexpectedProduct
.”mockRepository.Verify(...)
: This line is crucial for verifying interactions. It asserts that a specific method on the mock was called (or not called) a certain number of times. This helps ensure that ourProductService
is interacting with its dependencies as expected.- Speed: Because no real database calls are made, these tests run extremely fast, allowing for quick feedback during development.