Isaac.

Unit and Integration Testing

Learn the differences between unit and integration testing and how to implement both in your application test strategy.

By EMEPublished: February 20, 2025
testingunit testingintegration testingtest strategyquality assuranceautomated testing

A Simple Analogy

Imagine testing a car before it leaves the factory:

  • Unit testing is like testing individual parts (engine, brakes, battery) in isolation on a test bench. You verify each part works perfectly.
  • Integration testing is like putting those parts together and testing the whole car on a track. You verify all the parts work together correctly.

Both are essential. A perfect engine alone won't help if it doesn't work with the transmission.


Unit Testing vs. Integration Testing

| Aspect | Unit Testing | Integration Testing | |--------|--------------|-------------------| | Scope | Single function/method | Multiple components together | | Dependencies | Mocked | Real (or testcontainers) | | Speed | Fast (milliseconds) | Slower (seconds) | | Setup | Simple | Complex | | Purpose | Verify logic | Verify components work together | | Coverage | Branch coverage | End-to-end flows |


What Is Unit Testing?

Unit testing verifies that individual pieces of code (functions, methods, classes) work correctly in isolation.

Characteristics:

  • Tests one thing in isolation
  • Mocks/stubs external dependencies
  • Fast execution
  • Easy to debug when they fail

Example:

public class PriceCalculator
{
    public decimal CalculateDiscount(decimal price, int loyaltyPoints)
    {
        if (loyaltyPoints >= 100)
            return price * 0.9m;  // 10% discount
        return price;
    }
}

// Unit test
[Fact]
public void CalculateDiscount_WithEnoughPoints_ReturnsDiscountedPrice()
{
    var calc = new PriceCalculator();
    var result = calc.CalculateDiscount(100, 100);
    Assert.Equal(90, result);
}

What Is Integration Testing?

Integration testing verifies that multiple components work together correctly. It tests the interactions between units.

Characteristics:

  • Tests multiple components together
  • Uses real dependencies (database, file system) or test containers
  • Slower execution
  • More realistic scenarios

Example:

public class OrderServiceIntegrationTests
{
    [Fact]
    public async Task CreateOrder_ValidData_SavesAndSendsNotification()
    {
        // Arrange - use real database or test container
        var options = new DbContextOptionsBuilder<OrderContext>()
            .UseInMemoryDatabase("test-db")
            .Options;
        
        var context = new OrderContext(options);
        var emailService = new FakeEmailService(); // Real impl or test double
        var service = new OrderService(context, emailService);

        var order = new Order { CustomerId = 1, Total = 100 };

        // Act
        await service.CreateOrderAsync(order);

        // Assert
        var savedOrder = context.Orders.FirstOrDefault(o => o.Id == order.Id);
        Assert.NotNull(savedOrder);
        Assert.True(emailService.WasEmailSent);
    }
}

The Testing Pyramid

        /\          End-to-End Tests (UI, manual)
       /  \         5-10%
      /____\
     /      \       Integration Tests
    /        \      15-20%
   /____    ____\
  /        \/        \   Unit Tests
 /                    \ 70-80%
/______________________\

Best practice: More unit tests (fast, focused), fewer integration tests (slow, expensive), minimal E2E tests (manual).


Unit Testing Best Practices

1. Test One Thing Per Test

// Good - specific test name
[Fact]
public void Add_PositiveNumbers_ReturnsSum() { }

[Fact]
public void Add_NegativeNumbers_ReturnsNegativeSum() { }

// Bad - tests multiple scenarios
[Fact]
public void TestAdd() { }

2. Use Arrange-Act-Assert

[Fact]
public void GetUser_ExistingId_ReturnsUser()
{
    // Arrange: Setup
    var repo = new Mock<IUserRepository>();
    repo.Setup(r => r.GetAsync(1))
        .ReturnsAsync(new User { Id = 1, Name = "Alice" });

    var service = new UserService(repo.Object);

    // Act: Execute
    var result = await service.GetUserAsync(1);

    // Assert: Verify
    Assert.NotNull(result);
    Assert.Equal("Alice", result.Name);
}

3. Mock External Dependencies

[Fact]
public void SendOrder_CallsEmailService()
{
    var mockEmail = new Mock<IEmailService>();
    var service = new OrderService(mockEmail.Object);

    service.SendOrder(new Order { Email = "test@example.com" });

    // Verify the method was called
    mockEmail.Verify(
        e => e.Send("test@example.com", It.IsAny<string>()),
        Times.Once);
}

Integration Testing Best Practices

1. Use TestContainers for Real Dependencies

public class DatabaseIntegrationTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _container = 
        new PostgreSqlBuilder()
            .WithImage("postgres:15")
            .Build();

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
    }

    public async Task DisposeAsync()
    {
        await _container.StopAsync();
    }

    [Fact]
    public async Task InsertUser_ValidData_SavesSuccessfully()
    {
        var connectionString = _container.GetConnectionString();
        using var context = new AppContext(connectionString);

        await context.Users.AddAsync(new User { Name = "Alice" });
        await context.SaveChangesAsync();

        var user = await context.Users.FirstOrDefaultAsync(u => u.Name == "Alice");
        Assert.NotNull(user);
    }
}

2. Test API Endpoints

[Fact]
public async Task GetUser_WithValidId_Returns200()
{
    var client = _factory.CreateClient();
    
    var response = await client.GetAsync("/api/users/1");
    
    response.StatusCode.Should().Be(HttpStatusCode.OK);
    var user = await response.Content.ReadAsAsync<User>();
    user.Id.Should().Be(1);
}

3. Test Real Database Operations

[Fact]
public async Task CreateOrder_WithItems_CalculatesTotalCorrectly()
{
    using var context = new OrderContext(GetTestOptions());
    var service = new OrderService(context);

    var order = new Order { CustomerId = 1 };
    order.Items.Add(new OrderItem { ProductId = 1, Price = 10, Quantity = 2 });
    order.Items.Add(new OrderItem { ProductId = 2, Price = 5, Quantity = 1 });

    await service.CreateOrderAsync(order);
    
    var saved = await context.Orders.Include(o => o.Items)
        .FirstOrDefaultAsync(o => o.Id == order.Id);
    
    Assert.Equal(25, saved.Total);  // (10*2) + (5*1)
}

Practical Examples

E-commerce Order Flow (Complete Test Strategy)

// UNIT TESTS - Test logic in isolation
public class PriceCalculatorTests
{
    [Theory]
    [InlineData(100, 0.1, 90)]
    [InlineData(50, 0.2, 40)]
    public void ApplyDiscount_ReturnsCorrectPrice(decimal price, decimal discount, decimal expected)
    {
        var calc = new PriceCalculator();
        Assert.Equal(expected, calc.ApplyDiscount(price, discount));
    }
}

// INTEGRATION TESTS - Test components together
public class OrderServiceIntegrationTests
{
    [Fact]
    public async Task CreateOrder_ValidOrder_SavesAndRecalculatesTotal()
    {
        using var context = new OrderContext(GetTestDb());
        var calculator = new PriceCalculator();
        var service = new OrderService(context, calculator);

        var order = new Order
        {
            CustomerId = 1,
            Items = new[]
            {
                new OrderItem { ProductId = 1, Price = 100, Quantity = 1 }
            }
        };

        await service.CreateOrderAsync(order);

        var saved = await context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == order.Id);

        Assert.NotNull(saved);
        Assert.Equal(100, saved.Total);
    }
}

Real-World Scenarios

  • E-commerce: Unit test payment validation, integration test checkout flow with real payment gateway
  • Healthcare: Unit test diagnosis logic, integration test patient record updates with database
  • Banking: Unit test interest calculations, integration test fund transfers between accounts
  • Social media: Unit test follower count logic, integration test feed generation with real data

Common Pitfalls

Over-Testing Implementation Details

// Bad - tests private method directly
[Fact]
public void PrivateMethod_ReturnsValue() { }

// Good - test public behavior
[Fact]
public void PublicMethod_WithInput_ProducesExpectedOutput() { }

Too Many Integration Tests

// Too slow if every test requires database
[Fact]
public void Add_TwoNumbers_ReturnsSum() { }  // Don't use integration test for this!

Brittle Tests

// Bad - depends on exact string format
Assert.Equal("User: Alice | Age: 30", user.ToString());

// Good - test behavior
Assert.Equal("Alice", user.Name);
Assert.Equal(30, user.Age);

Test Organization

MyApp/
  MyApp.csproj
  
MyApp.UnitTests/              # Fast, isolated tests
  CalculatorTests.cs
  UserServiceTests.cs
  
MyApp.IntegrationTests/       # Real components, slower
  OrderServiceIntegrationTests.cs
  DatabaseTests.cs
  
MyApp.E2ETests/               # UI automation (optional)
  CheckoutFlowTests.cs

Best Practices Summary

Unit Testing:

  • Test one thing per test
  • Mock all external dependencies
  • Use descriptive names
  • Keep tests fast
  • Test both happy path and edge cases

Integration Testing:

  • Use real or containerized dependencies
  • Test realistic workflows
  • Include error scenarios
  • Don't over-test (fewer tests, more realistic)
  • Use test data builders

Related Concepts to Explore

  • Test-Driven Development (TDD)
  • Continuous Integration (CI) pipelines
  • Code coverage metrics
  • Test fixtures and factories
  • Behavior-Driven Development (BDD)
  • Performance testing
  • Load and stress testing
  • Security testing
  • Contract testing (for APIs)
  • Test data management

Summary

Unit and integration testing form the backbone of reliable software. Unit tests catch bugs early and document expected behavior; integration tests ensure components work together. Together, they create a safety net that lets you refactor and ship with confidence. Start with a strong base of unit tests, add integration tests for critical workflows, and your application will be more robust and maintainable.