Back to Blog

How to Become More Productive Writing Unit Tests

July 05, 2025

Featured image for How to Become More Productive Writing Unit Tests

Introduction

In the first part of this series, Cut Your Mock Setup Time with DepenMock: Effortless .NET Unit Testing, I introduced DepenMock and how it can help get older legacy codebases into a testing harness. How to Take Full Control of Your Mocks with DepenMock demonstrated advanced usages, like replacing mocks with custom instances, and easier testing of multiple classes that use the same interface (i.e. Strategy Pattern). This post will cover how to become more productive writing unit tests, whether you're using DepenMock, or any other testing library or framework.

1. Writing Re-usable Assertions and Setup Logic

One of the biggest productivity gains in unit testing comes from eliminating repetitive code. When you find yourself writing the same verification logic or setup patterns repeatedly, it's time to create reusable extensions. This approach not only speeds up test writing but also makes your tests more readable and maintainable.

Consider this common scenario: you have an OrderService that processes payments and sends confirmation emails.

public class OrderService
{
    private readonly IPaymentProcessor _payment;
    private readonly IEmailSender _email;

    public OrderService(IPaymentProcessor payment, IEmailSender email)
    {
        _payment = payment;
        _email = email;
    }

    public bool SubmitOrder(Order order)
    {
        var success = _payment.Charge(order.Total);
        if (success) _email.Send(order.Email, "Thanks for your order!");
        return success;
    }
}

The typical verification to check that a payment was charged for a specific amount would look like:

// Arrange
var chargeAmount = 200.0;
var mockPaymentProcessor = Container.ResolveMock<IPaymentProcessor>();
var order = Container
    .Build<Order>()
    .With(x => x.Total, chargeAmount)
    .Create();
var sut = ResolveSut();

// Act
sut.SubmitOrder(order);

// Assert
mockPaymentProcessor.Verify(x => x.Charge(chargeAmount), Times.Once);

This works fine for a single test, but what happens when you need to verify payment charges across dozens of tests? You'll find yourself repeating the same verification logic over and over. This is where extension methods come to the rescue.

public static class MockPaymentProcessorExtensions
{
    public static void AssertPaymentWasChargedFor(this Mock<IPaymentProcessor> mock, double amount)
    {
        AssertPaymentWasChargedFor(mock, amount, Times.Once);
    }

    // This override allows you to verify a payment was charged multiple times by accepting an additional parameter for Times
    public static void AssertPaymentWasChargedFor(this Mock<IPaymentProcessor> mock, double amount, Func<Times> times)
    {
        mock.Verify(x => x.Charge(amount), times);
    }
}

Now you have a reusable extension method for your tests.

// Arrange
var chargeAmount = 200.0;
var mockPaymentProcessor = Container.ResolveMock<IPaymentProcessor>();
var order = Container
    .Build<Order>()
    .With(x => x.Total, chargeAmount)
    .Create();
var sut = ResolveSut();

// Act
sut.SubmitOrder(order);

// Assert
mockPaymentProcessor.AssertPaymentWasChargedFor(chargeAmount);

You can also use extension methods to set up your mocks to return specific data. For example, let's say you want to verify that an email was not sent when a payment charge was not successful.

// MockPaymentProcessorExtensions.cs
public static class MockPaymentProcessorExtensions
{
    ... // previous methods excluded for clarity

    public static void SetupNonSuccessfulPayment(this Mock<IPaymentProcessor> mock)
    {
        mock.Setup(x => x.Charge(It.IsAny<double>)).Returns(false);
    }
}

// MockEmailSenderExtensions.cs
public static class MockEmailSenderExtensions
{
    public static void VerifyEmailSent(this Mock<IEmailSender> mock, Func<Times> times)
    {
        mock.Verify(x => x.Send(It.IsAny<string>(), It.IsAny<string>()), times);
    }

    public static void VerifyEmailWasNotSent(this Mock<IEmailSender> mock)
    {
        mock.Verify(x => x.Send(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
    }
}

// Unit test code

// Arrange
var mockEmailSender = Container.ResolveMock<IEmailSender>();
Container.ResolveMock<IPaymentProcessor>().SetupNonSuccessfulPayment();
var sut = ResolveSut();

// Act
sut.SubmitOrder(Container.Create<Order>());

// Assert
mockEmailSender.VerifyEmailWasNotSent();

The test is now cleaner and reads better. Now you could have verified that an email was not sent using the first extension method by using mockEmailSender.VerifyEmailSent(Times.Never), but having an explicit method for that scenario reads better.

2. Test Organization and Structure

The AAA (Arrange-Act-Assert) pattern is the foundation of unit testing, but as your test suite grows, you'll need more sophisticated approaches to keep your tests readable and maintainable. Let's explore some advanced variations that can significantly improve your testing productivity.

2.1 Given-When-Then Pattern

The Given-When-Then pattern is a more descriptive variation of AAA that reads like a specification. This approach makes your tests self-documenting and easier for non-technical stakeholders to understand.

[Test]
public void GivenAValidOrder_WhenSubmitted_ThenShouldChargePayment()
{
    // Given
    var order = Container.Build<Order>()
        .With(x => x.Total, 200.0)
        .Create();
    var mockPaymentProcessor = Container.ResolveMock<IPaymentProcessor>();
    var mockEmailSender = Container.ResolveMock<IEmailSender>();
    var sut = ResolveSut();

    // When
    var result = sut.SubmitOrder(order);

    // Then
    mockPaymentProcessor.AssertPaymentWasChargedFor(200.0);
}

This pattern is particularly effective when:

  • You're writing behavior-driven development (BDD) tests
  • You need to communicate test scenarios with business stakeholders
  • Your tests involve complex business logic with multiple steps

2.2 Setup-Act-Assert with Shared Context

When multiple tests share similar setup logic, you can extract common arrange code into setup methods. This reduces duplication and makes your tests more maintainable.

public class OrderServiceTests : BaseTestByAbstraction<OrderService, IOrderService>
{
    private OrderService _sut;
    private Mock<IPaymentProcessor> _paymentProcessor;
    private Mock<IEmailSender> _emailSender;
    private Order _defaultOrder;

    [SetUp]
    public void Setup()
    {
        // Shared Arrange logic
        _paymentProcessor = Container.ResolveMock<IPaymentProcessor>();
        _emailSender = Container.ResolveMock<IEmailSender>();
        _defaultOrder = Container.Build<Order>()
            .With(x => x.Total, 100.0)
            .With(x => x.Email, "test@example.com")
            .Create();
        _sut = ResolveSut();
    }

    [Test]
    public void SubmitOrder_WithValidOrder_ShouldReturnTrue()
    {
        // Act
        var result = _sut.SubmitOrder(_defaultOrder);

        // Assert
        Assert.That(result, Is.True);
    }

    [Test]
    public void SubmitOrder_WithInvalidOrder_ShouldReturnFalse()
    {
        // Arrange
        _paymentProcessor.SetupNonSuccessfulPayment();

        // Act
        var result = _sut.SubmitOrder(_defaultOrder);

        // Assert
        Assert.That(result, Is.False);
    }
}

2.3 Builder Pattern in Arrange

For complex test data, the builder pattern provides a fluent, readable way to create test objects. This is especially useful when you need to create objects with many properties or nested objects.

// Arrange
var order = OrderBuilder.Create()
    .WithCustomer(customer => customer
        .Email("john@example.com")
        .Name("John Doe")
        .Phone("555-0123"))
    .WithItems(items => items
        .Add("Product A", 2, 50.00)
        .Add("Product B", 1, 25.00)
        .Add("Product C", 3, 10.00))
    .WithShippingAddress(address => address
        .Street("123 Main St")
        .City("Anytown")
        .State("CA")
        .ZipCode("12345"))
    .WithPaymentMethod(payment => payment
        .Type(PaymentType.CreditCard)
        .LastFourDigits("1234"))
    .Build();

var mockPaymentProcessor = Container.ResolveMock<IPaymentProcessor>();
var sut = ResolveSut();

// Act
var result = sut.SubmitOrder(order);

// Assert
Assert.That(result, Is.True);
mockPaymentProcessor.AssertPaymentWasChargedFor(155.00); // 2*50 + 1*25 + 3*10

2.4 Table-Driven Tests

Table-driven tests allow you to test multiple scenarios with the same test logic, reducing code duplication and making it easy to add new test cases.

// NUnit example
[TestCase(100.00, true, "Valid order should succeed")]
[TestCase(0.00, false, "Zero amount should fail")]
[TestCase(-50.00, false, "Negative amount should fail")]
[TestCase(1000000.00, false, "Amount too high should fail")]
public void SubmitOrder_WithVariousAmounts_ShouldValidateCorrectly(
    decimal amount,
    bool expectedResult,
    string testDescription)
{
    // Arrange
    var order = Container.Build<Order>()
        .With(x => x.Total, amount)
        .Create();
    var sut = ResolveSut();

    // Act
    var result = sut.SubmitOrder(order);

    // Assert
    Assert.That(result, Is.EqualTo(expectedResult), testDescription);
}

// XUnit example
[Theory]
[InlineData(100.00, true, "Valid order should succeed")]
[InlineData(0.00, false, "Zero amount should fail")]
[InlineData(-50.00, false, "Negative amount should fail")]
[InlineData(1000000.00, false, "Amount too high should fail")]
public void SubmitOrder_WithVariousAmounts_ShouldValidateCorrectly(
    decimal amount,
    bool expectedResult,
    string testDescription)
{
    ...
}

// MSTest example
[TestMethod]
[DataRow(100.00, true, "Valid order should succeed")]
[DataRow(0.00, false, "Zero amount should fail")]
[DataRow(-50.00, false, "Negative amount should fail")]
[DataRow(1000000.00, false, "Amount too high should fail")]
public void SubmitOrder_WithVariousAmounts_ShouldValidateCorrectly(
    decimal amount,
    bool expectedResult,
    string testDescription)
{
    ...
}

2.5 Test Data Factories

Centralize test data creation in factory classes to ensure consistency across your test suite and make it easy to create test data with specific characteristics.

public static class OrderTestDataFactory
{
    public static Order CreateValidOrder()
    {
        return Container.Build<Order>()
            .With(x => x.Total, 100.0)
            .With(x => x.Email, "test@example.com")
            .With(x => x.CustomerName, "Test Customer")
            .Create();
    }

    public static Order CreateOrderWithAmount(decimal amount)
    {
        return Container.Build<Order>()
            .With(x => x.Total, amount)
            .With(x => x.Email, "test@example.com")
            .Create();
    }

    public static Order CreateOrderForCustomer(string email, string customerName)
    {
        return Container.Build<Order>()
            .With(x => x.Total, 100.0)
            .With(x => x.Email, email)
            .With(x => x.CustomerName, customerName)
            .Create();
    }
}

[Test]
public void SubmitOrder_WithValidOrder_ShouldProcessCorrectly()
{
    // Arrange
    var order = OrderTestDataFactory.CreateValidOrder();
    var mockEmailSender = Container.ResolveMock<IEmailSender>();
    var sut = ResolveSut();

    // Act
    var result = sut.SubmitOrder(order);

    // Assert
    mockEmailSender.VerifyEmailSent(Times.Once);
}

2.6 Choosing the Right Pattern

The key to productivity is choosing the right pattern for your specific situation:

  • Given-When-Then: Use for complex business logic tests that need to be understood by non-developers
  • Shared Setup: Use when multiple tests share similar arrange logic
  • Builder Pattern: Use for creating complex test objects with many properties
  • Table-Driven Tests: Use when testing multiple scenarios with the same logic
  • Test Data Factories: Use when you need consistent test data across multiple tests

By mastering these patterns, you'll be able to write more readable, maintainable tests that are easier to understand and modify as your codebase evolves.

3. Advanced Productivity Techniques

As you become more comfortable with the fundamentals of unit testing, you can leverage advanced techniques to further boost your productivity. These techniques help you write tests faster, maintain them more easily, and catch issues earlier in the development cycle.

3.1 Custom Test Attributes

Custom test attributes encapsulates cross-cutting concerns that help make your tests more concise and focused on the actual test logic rather than boilerplate code.

Note: The custom test attributes shown in this section are specific to NUnit. If you're using XUnit or MSTest, you'll need to implement similar functionality using their respective extension points and attributes.

Performance Test Attribute

Create an attribute that measures test execution time and fails if it exceeds a threshold:

[AttributeUsage(AttributeTargets.Method)]
public class PerformanceTestAttribute : TestAttribute
{
    private readonly int _maxMilliseconds;

    public PerformanceTestAttribute(int maxMilliseconds = 100)
    {
        _maxMilliseconds = maxMilliseconds;
    }

    public override void RunTest(TestExecutionContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        try
        {
            base.RunTest(context);
        }
        finally
        {
            stopwatch.Stop();

            if (stopwatch.ElapsedMilliseconds > _maxMilliseconds)
            {
                Assert.Fail($"Test took {stopwatch.ElapsedMilliseconds}ms, which exceeds the maximum of {_maxMilliseconds}ms");
            }
        }
    }
}

public class OrderServicePerformanceTests
{
    [Test]
    [PerformanceTest(50)]
    public void SubmitOrder_ShouldCompleteWithin50ms()
    {
        var order = Container.Create<Order>();
        var sut = ResolveSut();

        var result = sut.SubmitOrder(order);

        Assert.That(result, Is.True);
    }
}

3.2 Memory Usage Monitoring

Monitor memory usage in your tests to catch memory leaks:

public static class MemoryHelper
{
    public static long GetCurrentMemoryUsage()
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        return GC.GetTotalMemory(false);
    }

    public static void AssertMemoryUsage(Action action, long maxBytesIncrease = 1024 * 1024) // 1MB default
    {
        var before = GetCurrentMemoryUsage();
        action();
        var after = GetCurrentMemoryUsage();
        var increase = after - before;

        Assert.That(increase, Is.LessThanOrEqualTo(maxBytesIncrease),
            $"Memory usage increased by {increase} bytes, which exceeds the maximum of {maxBytesIncrease} bytes");
    }
}

public class OrderServiceMemoryTests
{
    [Test]
    public void SubmitOrder_ShouldNotLeakMemory()
    {
        var sut = ResolveSut();

        MemoryHelper.AssertMemoryUsage(() =>
        {
            for (int i = 0; i < 1000; i++)
            {
                var order = Container.Create<Order>();
                sut.SubmitOrder(order);
            }
        }, maxBytesIncrease: 1024 * 1024); // 1MB
    }
}

These advanced techniques can significantly improve your testing productivity by automating repetitive tasks, catching performance issues early, and providing better insights into your test suite's health.

Summary

Unit testing productivity isn't just about writing tests faster, it's about creating a sustainable, maintainable testing ecosystem that grows with your codebase. Throughout this post, we've explored techniques that can transform your testing workflow, whether you're using DepenMock or any other testing framework.

Key Productivity Gains

1. Eliminate Repetitive Code with Reusable Extensions

  • Create extension methods for common mock verifications and setups
  • Transform verbose verification code into readable, intent-expressing methods
  • Build a library of domain-specific testing utilities that your team can reuse
  • Example: mockPaymentProcessor.AssertPaymentWasChargedFor(amount) instead of mockPaymentProcessor.Verify(x => x.Charge(amount), Times.Once)

2. Structure Tests for Maximum Readability

  • Given-When-Then: Perfect for BDD scenarios and complex business logic
  • Shared Setup: Reduce duplication when multiple tests share similar arrange logic
  • Builder Pattern: Create complex test objects with fluent, readable syntax
  • Table-Driven Tests: Test multiple scenarios with minimal code duplication
  • Test Data Factories: Centralize test data creation for consistency

3. Leverage Advanced Techniques for Quality Assurance

  • Custom Test Attributes: Automate cross-cutting concerns like performance monitoring
  • Memory Usage Monitoring: Catch memory leaks early in the development cycle
  • Performance Testing: Ensure your code meets performance requirements

Choosing the Right Approach

The most productive testing strategy depends on your specific context:

  • New codebases: Start with extension methods and test data factories to establish good patterns early
  • Legacy code: Focus on shared setup and table-driven tests to reduce the overhead of testing complex existing code
  • Performance-critical systems: Implement custom attributes for performance and memory monitoring
  • Business-focused teams: Adopt Given-When-Then patterns to make tests accessible to non-technical stakeholders

Implementation Strategy

  1. Start Small: Begin with one or two extension methods for your most common verification patterns
  2. Build Incrementally: Add new patterns as you identify repetitive code in your tests
  3. Team Adoption: Share your testing utilities with your team and establish conventions
  4. Continuous Improvement: Regularly review your test suite to identify new opportunities for productivity gains

Beyond the Code

Remember that productivity in unit testing extends beyond just writing code faster. The techniques we've covered also contribute to:

  • Better Test Coverage: More maintainable tests encourage developers to write more comprehensive test suites
  • Faster Debugging: Well-structured tests with clear intent make it easier to identify and fix issues
  • Improved Code Quality: The act of writing tests often reveals design issues that can be addressed early
  • Knowledge Sharing: Readable tests serve as documentation for how your code should behave

Next Steps

Whether you're just starting your unit testing journey or looking to optimize an existing test suite, these techniques provide a solid foundation for building a productive testing culture. Start with the patterns that resonate most with your current challenges, and gradually incorporate others as your testing needs evolve.

The goal isn't to implement every technique at once, but to create a testing ecosystem that makes writing good tests as natural and efficient as possible. With these tools in your arsenal, you'll find that unit testing becomes less of a chore and more of a powerful ally in building robust, maintainable software.


Ready to take your testing productivity to the next level? Check out the DepenMock documentation to see how these techniques work seamlessly with modern .NET testing frameworks.


Profile picture

Written by Shannon Stewart I am a software architect and engineer focused on Azure, C#, and AI. I write about real-world projects, cloud-native patterns, and building smarter systems with modern tools.