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.
Related Articles
Unit Testing Best Practices
Write effective unit tests that catch bugs and enable refactoring.
Read More testingIntegration Testing with ASP.NET Core
Write comprehensive integration tests for real-world scenarios.
Read More testingUnit Testing with xUnit
Write effective unit tests with xUnit and assertion libraries.
Read More