DEV Community

Cover image for Acceptance Testing Strategies (Part 2): Feature Tests, Pyramid, and Best Practices
Outdated Dev
Outdated Dev

Posted on

Acceptance Testing Strategies (Part 2): Feature Tests, Pyramid, and Best Practices

In Part 1 we covered what acceptance tests are, plus inside-out and outside-in testing. Here we add the third strategy (feature tests) then compare all three, look at the testing pyramid, and share best practices for combining them.

← Part 1: Inside-Out and Outside-In


3. Feature Tests

What are Feature Tests?

Feature tests are comprehensive tests that validate complete features or user stories from end to end. They combine elements of both inside-out and outside-in testing to ensure features work correctly across all layers of the application.

Think of feature tests as the "final exam" for a complete feature—they verify everything works together as expected.

Characteristics

  • Feature-focused: Tests complete features, not individual components
  • Cross-layer: Spans multiple layers of the application
  • Business-driven: Based on user stories and business requirements
  • Integration-heavy: Tests how different components work together

Example Implementation

public class OrderManagementFeatureTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public OrderManagementFeatureTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _client = _factory.CreateClient();
    }

    [Fact]
    public async Task Feature_OrderManagement_CompleteOrderLifecycle()
    {
        // Arrange: Create test data
        var customer = await CreateTestCustomer();
        var product = await CreateTestProduct();

        // 1. Create Order
        var createOrderRequest = new CreateOrderRequest
        {
            CustomerId = customer.Id,
            Items = new List<OrderItemRequest>
            {
                new OrderItemRequest { ProductId = product.Id, Quantity = 2 }
            }
        };

        var createResponse = await _client.PostAsJsonAsync("/api/orders", createOrderRequest);
        createResponse.EnsureSuccessStatusCode();
        var order = await createResponse.Content.ReadFromJsonAsync<Order>();

        Assert.NotNull(order);
        Assert.Equal(OrderStatus.Pending, order.Status);

        // 2. Process Payment
        var paymentRequest = new ProcessPaymentRequest
        {
            OrderId = order.Id,
            PaymentMethod = "credit_card",
            Amount = order.Total
        };

        var paymentResponse = await _client.PostAsJsonAsync("/api/payments", paymentRequest);
        paymentResponse.EnsureSuccessStatusCode();
        var payment = await paymentResponse.Content.ReadFromJsonAsync<Payment>();

        Assert.True(payment.IsSuccessful);

        // 3. Confirm Order
        var confirmResponse = await _client.PostAsync($"/api/orders/{order.Id}/confirm", null);
        confirmResponse.EnsureSuccessStatusCode();

        // 4. Verify Order Status
        var orderResponse = await _client.GetAsync($"/api/orders/{order.Id}");
        orderResponse.EnsureSuccessStatusCode();
        var confirmedOrder = await orderResponse.Content.ReadFromJsonAsync<Order>();

        Assert.Equal(OrderStatus.Confirmed, confirmedOrder.Status);
    }

    private async Task<Customer> CreateTestCustomer()
    {
        var customerRequest = new CreateCustomerRequest
        {
            Name = "Test Customer",
            Email = "test@example.com"
        };

        var response = await _client.PostAsJsonAsync("/api/customers", customerRequest);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Customer>();
    }

    private async Task<Product> CreateTestProduct()
    {
        var productRequest = new CreateProductRequest
        {
            Name = "Test Product",
            Price = 29.99m,
            Stock = 100
        };

        var response = await _client.PostAsJsonAsync("/api/products", productRequest);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Product>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Advantages

  • Complete Coverage: Tests entire features end-to-end
  • Business Value: Ensures features deliver expected business value
  • Integration Validation: Tests how different components work together
  • User Story Validation: Directly maps to user stories and requirements
  • Regression Prevention: Prevents feature regressions

Disadvantages

  • Slow Execution: Feature tests are typically slower than unit tests
  • Complex Setup: Requires more complex test data and environment setup
  • Maintenance Overhead: Can be harder to maintain as features evolve
  • Debugging Difficulty: Failures can be harder to debug and isolate

When to Use Feature Tests

  • Critical business features
  • User story validation
  • Integration testing
  • Regression testing
  • Release validation

Comparing the Strategies

Quick Comparison

Aspect Inside-Out Outside-In Feature Tests
Starting Point Core business logic User interface Complete features
Focus Technical quality User experience Business value
Test Speed Fast (unit tests) Slow (UI tests) Medium (integration)
Maintenance Easy Hard Medium
Business Alignment Low High High
Technical Coverage High Medium Medium
Feedback Speed Fast Slow Medium

The Testing Pyramid

The pyramid reminds us to have few slow, expensive tests at the top and many fast, cheap tests at the bottom:

                    E2E / Feature tests
                  (few, slow, high value)
                         /\
                        /  \
                       /----\
                      /      \
         Integration /  API   \ (some, medium)
                    /  tests  \
                   /------------\
                  /              \
     Unit tests  /   (many, fast) \  ← base of the pyramid
                /__________________\
Enter fullscreen mode Exit fullscreen mode
Layer Count Speed Purpose
Top Few Slow E2E / feature tests; full user journey, high confidence
Middle Some Medium Integration / API tests; component interaction
Bottom Many Fast Unit tests; technical quality, component isolation

Where these tests usually run (CI/CD)

In practice, unit tests run on every commit or PR; integration/API tests often run in the same pipeline or on merge; acceptance/feature/E2E tests may run on a schedule (e.g. nightly), on release branches, or in a staging environment because they're slower and need more infrastructure. Balancing speed and confidence here is a key responsibility for SDETs and DevOps.

Best Practices

1. Use a Hybrid Approach

Combine all three strategies for comprehensive coverage:

// 1. Unit Tests (Inside-Out) - Fast feedback, technical quality
[Fact]
public void OrderService_CalculateTotal_WithValidItems_ReturnsCorrectTotal()
{
    // Test core business logic
}

// 2. Integration Tests (Inside-Out) - Component interaction
[Fact]
public async Task OrderController_CreateOrder_WithValidRequest_ReturnsCreatedOrder()
{
    // Test API layer
}

// 3. Feature Tests (Outside-In) - Business value validation
[Fact]
public async Task Feature_OrderManagement_CompleteOrderLifecycle()
{
    // Test complete feature
}
Enter fullscreen mode Exit fullscreen mode

2. Organize Tests by Feature

// xUnit: use nested classes or separate test classes by feature
public class OrderCreationTests
{
    [Fact]
    public void CreateOrder_WithValidData_ReturnsSuccess() { }

    [Fact]
    public void CreateOrder_WithInvalidData_ReturnsValidationError() { }
}

public class OrderProcessingTests
{
    [Fact]
    public void ProcessOrder_WithValidPayment_ConfirmsOrder() { }
}
Enter fullscreen mode Exit fullscreen mode

3. Use Test Data Builders

public class OrderBuilder
{
    private Order _order = new Order();

    public OrderBuilder WithCustomer(int customerId)
    {
        _order.CustomerId = customerId;
        return this;
    }

    public OrderBuilder WithItem(int productId, int quantity)
    {
        _order.Items.Add(new OrderItem { ProductId = productId, Quantity = quantity });
        return this;
    }

    public Order Build() => _order;
}

// Usage
var order = new OrderBuilder()
    .WithCustomer(123)
    .WithItem(1, 2)
    .WithItem(2, 1)
    .Build();
Enter fullscreen mode Exit fullscreen mode

4. Keep Tests Independent

Each test should be able to run independently without relying on other tests. Use setup and teardown methods to ensure clean test state. For UI acceptance tests, prefer stable selectors (e.g. role, label, test IDs used sparingly) and avoid coupling to implementation details so tests survive refactors; SDETs often use Page Object Model or similar to keep automation maintainable.

5. Write Descriptive Test Names

Test names should clearly describe what is being tested (and, for acceptance tests, ideally reflect the scenario or acceptance criterion):

// Good
[Fact]
public void Customer_Can_Complete_Purchase_WithValidPaymentDetails()

// Bad
[Fact]
public void Test1()
Enter fullscreen mode Exit fullscreen mode

Recommended Strategy

For most projects, a hybrid approach works best:

  • Start with Outside-In for new features to ensure business alignment
  • Use Inside-Out for technical debt reduction and component quality
  • Implement Feature Tests for critical business functionality
  • Maintain a healthy test pyramid with more unit tests than integration tests

Conclusion

Acceptance testing is crucial for ensuring your software meets business requirements and delivers value to users. The choice between inside-out, outside-in, and feature testing approaches depends on your specific needs, team structure, and project requirements.

Key Takeaways:

  1. Inside-Out Testing is ideal for technical quality and component isolation
  2. Outside-In Testing focuses on user experience and business value
  3. Feature Tests provide comprehensive end-to-end validation
  4. Hybrid Approach combining all three strategies offers the best coverage

Remember: The goal is not to use every testing approach, but to find the right combination that ensures quality, meets business needs, and fits your team's capabilities.

Start by assessing your current testing strategy, identify gaps, and gradually introduce the approaches that make sense for your project. Happy testing! 🚀


← Part 1: Inside-Out and Outside-In

Top comments (0)