Unit and Integration Testing
Learn the differences between unit and integration testing and how to implement both in your application test strategy.
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.