Why Use Automated Testing?

Here are a few good reasons to consider automated testing:

  • Minimise emergency fixes to production environments.
  • Minimise breaks to seemingly unrelated areas when a change is made to the source code. This is particularly important when collaborating with other developers, enabling productive teamwork.
  • Help to find defects and regressions. This leads to happier:
    • End-users
    • Business
    • Developers
  • Reduce the cost of ownership and therefore the cost of software development.
  • Increase the long-term speed of development.

My Elevator Pitch for TDD

💂‍♂️💂‍♂️💂‍♂️ In simple terms I like to think of tests as a garrison of guards protecting business logic. 💂‍♂️💂‍♂️💂‍♂️

Automated tests give us greater confidence that the software is working correctly.

It should also be noted that:

  • Quick to execute.
  • Free to run as often as required.

Let's Get Practical

Setting Up an NUnit Test Project

  1. Add a Class Library.
    • Add a Class Library (.NET x) project to your solution. Where x is either Framework or Core.
    • Name it ns.Tests. Where ns is the current namespace of the solution being tested. For example HomeRental.Tests.
    • Delete the class file Class1.cs.
  2. Add a reference to the main project in the ns.Tests project.
  3. Add the following NuGet packages to the ns.Tests project:
    • NUnit
    • NUnit3TestAdapter
    • Microsoft.NET.Test.Sdk

Writing an NUnit Test

  1. Create a new public non static class in the ns.Test project. For example:

    [TestFixture]
    public class MortgageTermShould
    {
    }
    

    [TestFixture] is in the NUnit.Framework namespace and is optional for NUnit 3.

  2. Add a non static method that returns void. For example:

    [Test]
    public void ReturnTermInMonths()
    {
    }
  3. Add some code to the method.

    var sut = new MortgageTerm(1);
    Assert.That(sut.ToMonths(), Is.EqualTo(12));
  4. Where sut means System Under Test.

Running Tests in Visual Studio Test Explorer

Open the Test Explorer by selecting the Test | Windows | Test Explorer menu item.

Run all the tests by selecting Run All. The results will appear on the right-hand side.

Running Tests at the Command Line

Open a PowerShell window by doing the following:

  1. Right click on the test project and select Open Folder in File Explorer.
  2. Hold down SHIFT, right click on the Explorer Window and select Open PowerShell window here.

In the PowerShell type dotnet test. This will build the solution and then execute any test within the solution.

dotnet test --list-tests only lists all of the available tests.

dotnet test /? will show all of the options. See NUnit Arguments.

Understanding NUnit Tests

Understanding the NUnit Test Framework

This is divided into two parts NUnit Library Contains and Test Runner.

NUnit Library Contains:

  • Attributes - such as [Test].
  • Assertions.
  • Extensibility/Customisation.

Test Runner Does:

(Test Explorer and .NET Test Command Line)
  • Recognise attributes.
  • Execute test methods.
  • Report test results.

NUnit attributes Overview

[TestFixture] Mark a class that contains tests.
[Test] Mark a method as a test.
[Category] Organise tests into categories.
[TestCase] Data-driven test cases.
[Values] Data-driven test parameters.
[Sequential] How to combine test data.
[SetUp] Run code before each test.
[OneTimeSetUp] Run code before the first text in class executes.

NUint Assertions Overview

Two models for creating assertions:

  • Newer - Constraint Model of assertions. For example:
    Assert.That(sut.Years, Is.EqualTo(1));
    Generally:
    Assert.That(test result, constraint instance);
  • Older - Classic Model of assertions. For example:
    Assert.AreEqual(1, sut.Years);
    Assert.NotNull(sut.Years);

Recognising Different Testing Scenarios

  • Business Logic
  • Bad data/input
  • All code branches - Code converge.

The Logical

Arrange, Act, Assert Test Phases

  • Arrange: Set up test objects, initialise test data, etc.
  • Act: Call method, set properties, etc.
  • Assert Compare returned value/end state with expected.

For Example:

[Test]
public void MortgageTermInMonths()
{
  // Arrange
  var sut = new MortgageTerm(1);

  // Act
  var numberOfMonths = sut.ToMonths();

  // Assert
  Assert.That(numberOfMonths, Is.EqualTo(12));
}

The Arrange, Act, Assert pattern is a guide, you may choose not to have three explicit phases.

Adding a Second Test

[Test]
public void MortgageTermInYears()
{
  // Arrange
  int years = 1;
  var sut = new MortgageTerm(years);

  // Act
  var sutYears= sut.Years();

  // Assert
  Assert.That(sutYears, Is.EqualTo(years));
}

What Makes a Good Test?

  • Fast
  • Repeatable
  • Isolated
  • Trustworthy
  • Valuable

Asserting on Different Types of Results

The NUnit Constraint Model of Assertions

Asserts - Evaluate and verify the outcome of a test based on a returned result, final object state, or the occurrence of events observed during execution. An assert should either pass or fail.

Assert.That(sut.Years, Is.EqualTo(1));
Assert.That(sut.Years, new EqualConstraint(1));

Is.EqualTo(1) and new EqualConstraint(1) are functionally equivalent.

The Is Class

public abstract class Is
{
  // Additional code omitted
  public static EqualConstraint EqualTo(object expected)
  {
    return new EqualConstraint(expected); 
  }

  public static FalseConstraint False
  {
    get { return new FalseConstraint(); } 
  }

  public static GreaterThanConstraint GreaterThen(object expected)
  {
    return new GreaterThanConstraint(expecvted);
  }

  // etc.
}

The Is.Not Class

[Test]
public void StoreYears()
{
  // Arrange
  int years = 1;
  var sut = new MortgageTerm(years);

  // Act
  var sutYears= sut.Years();

  // Assert
  Assert.That(2, Is.Not.EqualTo(years)); // Pass
}

IResolveConstraint in Detail

There are a number of overloads to the That method.

... That(T actual,
         IResolveConstraint expression)
    { ... }

Note the following inheritance hierarchy:

public class EqualConstraint : Constraint
{ ... }

public abstract class Constraint : IConstraint
{ ... }

public interface IConstraint : IResolveConstraint
{ ... }

Therefore EqualConstraint inherits from IResolveConstraint.

How Many Asserts per Test?

A single test usually focuses on testing a single "behaviour".

Multiple asserts are usually OK if all the asserts are related to testing this single behaviour.

An Example of Multiple Asserts

[Test]
public void MortgageTermInYears()
{
  // Arrange
  int years = 1;
  var sut = new MortgageTerm(years);

  // Act
  var sutYears= sut.Years();

  // Assert
  Assert.That(sutYears,
              Is.EqualTo(years));
  Assert.That(sutYears,
              Is.GreaterThanConstraint(0));
}

Asserting on Equality

Comparing Value Types

[Test]
public void RespectValueEquality()
{
  var a = 1; // int
  var b = 1; // int

  // This Assert will pass as 
  // integers are value
  // and not reference types.
  Assert.That(a,
              Is.EqualTo(b)); 
}

Comparing Reference Types

By default Is.EqualTo assert will fail.

[Test]
public void RespectValueEquality()
{
  var a = new MortgageTerm(1); // reference types
  var b = new MortgageTerm(1); // reference types

  // This Assert will fail as 
  // objects are reference types.
  Assert.That(a,
              Is.EqualTo(b)); 
}

Asserting on Reference Equality

Use the SameAs static method to compare two reference types.

[Test]
public void ReferenceEqualityExample()
{
  var a = new MortgageTerm(1);
  var b = a;
  var c = new MortgageTerm(1);

  Assert.That(a,
              Is.SameAs(b));     // Pass
  Assert.That(a,
              Is.Not.SameAs(c)); // Pass
}

Adding Custom Failure Messages

[Test]
public void ReturnTermInMonths()
{
  // Arrange
  int monthsExpected= 12;
  var sut = new MortgageTerm(1);

  // Act
  var months= sut.ToMonths();

  // Assert
  Assert.That(months,
              Is.EqualTo(monthsExpected),
              "Months should be 12*number of years");
}

Asserting on Floating Point Values

[Test]
public void Double()
{
  // Arrange and Act
  double a = 1.0 /3.0;

  // Assert
  Assert.That(a,
              Is.EqualTo(0.33)
                .Within(0.004));  // Pass
  Assert.That(a,
              Is.EqualTo(0.33
                .Within(10).Percent); // Pass
}

Asserting on Collection Contents

[Test]
public void ReturnCorrectNumberIfComparisons()
{
  // Arrange
  var products = new List<MortgageProduct>
  {
    new MortgageProduct(1, "a", 1);
    new MortgageProduct(2, "b", 1);
    new MortgageProduct(3, "c", 3);
  };

  var sut = new ProductComparer(new LoadnAmount("USD",
                                                200_000m),
                                products);
  // _ - thousand separator
  // m - decimal literal

  // Act
  List<MonthlyRepaymentComparison> comparisions =
    sut.CompareMonthlyRepayments(new MortgageTerm(30));

  Assert
  Assert.That(comparisions,
              Has.Exactly(3)
                 .Items); // Pass
}



[Test]
public void NotReturnDuplicateComparisons()
{
  // Arrange
  var products = new List<MortgageProduct>
  {
    new MortgageProduct(1, "a", 1);
    new MortgageProduct(2, "b", 1);
    new MortgageProduct(3, "c", 3);
  };

  var sut = new ProductComparer(new LoadnAmount("USD",
                                                200_000m),
                                products);

  // Act
  List<MonthlyRepaymentComparison> comparisions
    = sut.CompareMonthlyRepayments(new MortgageTerm(30));


  // Assert
  Assert.That(comparisions,
              Is.Unique); // Pass
}



[Test]
public void ReturnComparisonForFirstProduct()
{
  // Arrange
  var products = new List<MortgageProduct>
  {
    new MortgageProduct(1, "a", 1);
    new MortgageProduct(2, "b", 1);
    new MortgageProduct(3, "c", 3);
  };

  var sut = new ProductComparer(new LoadnAmount("USD",
                                                200_000m),
                                products);

  // Act
  List<MonthlyRepaymentComparison> comparisions = sut.CompareMonthlyRepayments(new MortgageTerm(30));


  // Need to also know the expected monthly repayment
  var expectedProduct 
      = new MonthlyRepaymentComparision("a",
                                        1,
                                        643.28m);

  // Assert
  Assert.That(comparisions,
              Does.Contain(expectedProduct)); // Pass
}



[Test]
public void ReturnComparisonForFirstProduct_WithPartialKnownExpectedValues()
{
  // Arrange
  var products = new List<MortgageProduct>
  {
      new MortgageProduct(1, "a", 1);
      new MortgageProduct(2, "b", 1);
      new MortgageProduct(3, "c", 3);
  };

  var sut = new ProductComparer(new LoanAmount("USD",
                                               200_000m),
                                products);

  // Act
  List<MonthlyRepaymentComparison> comparisions
       = sut.CompareMonthlyRepayments(new MortgageTerm(30));


  // Assert
  // Don't care about the expected monthly repayment,
  // only that the product is there.
  // The properties are specified as strings,
  // Therefore this test will fail
  // if these string names change when refactoring.
  Assert.That(comparisions,
              Has.Exectly(1)
                 .Property("ProductName")
                 .EqualTo("a")
                 .And
                 .Property("InterestRate")
                 .EqualTo(1)
                 .And
                 .Property("MonthlyRepayment")
                 .GreaterThan(0)); // Pass

    // Type safe alternative
    Assert.That(comparisions,
                Has.Exectly(1)
                   .Matches<MonthlyRepaymentComparison>(
                           x => x.ProductName == "a"
                             && x.InterestRate == 1
                            && x.MonthlyRepayment > 0)); // Pass
}

Asserting that Exceptions are Thrown

[Test]
public void NotAllowZeroYears()
{
  Assert.That(() => new MortgageTerm(0),
              Throws.TypeOf<ArgumentOutOfRangeException>); // Pass

  Assert.That(() => new MortgageTerm(0),
              Throws.TypeOf<ArgumentOutOfRangeException>()
        .With
        .Property("Message")
        .EqualTo("Please specify a value greater than 0.\r\nParameter name: years"));
  // Pass


  // Type safe alternative
  Assert.That(() => new MortgageTerm(0),
              Throws.TypeOf<ArgumentOutOfRangeException>()
        .With
        .Message
        .EqualTo("Please specify a value greater than 0.\r\nParameter name: years")); // Pass

  // Correct ex and para name but don't care about the message
  Assert.That(() => new MortgageTerm(0),
              Throws.TypeOf<ArgumentOutOfRangeException>()
        .With
        .Property("Message")



  // Type safe alternative
  Assert.That(() => new MortgageTerm(0),
              Throws.TypeOf<ArgumentOutOfRangeException>()
        .With
        .Matches<ArgumentOutOfRangeException>(
              x => x.ParamName == "years"));
}

Other Assertion Examples

Asserting on Null Values

string name = "Delaney";

Assert.That(name, Is.Null);     // Fail
Assert.That(name, Is.Not.Null); // Pass

Asserting on String Values

string name = "Delaney";

Assert.That(name, Is.Empty);     // Fail
Assert.That(name, Is.Not.Empty); // Pass

Assert.That(name, Is.EqualTo("Delaney"));            // Pass
Assert.That(name, Is.EqualTo("DELANEY"));            // Fail
Assert.That(name, Is.EqualTo("DELANEY").IgnoreCase); // Pass

Assert.That(name, Does.StartWith("De"));           // Pass

Assert.That(name, Does.EndWith("ey"));             // Pass

Assert.That(name, Does.Contain("ane"));            // Pass

Assert.That(name, Does.Not.Contain("Boris"));      // Pass

Assert.That(name, Does.StartWith("De")
                      .And
                      .EndsWith("ney"));           // Pass

Assert.That(name, Does.StartWith("xyz")
                      .Or
                      .EndsWith("ney"));           // Pass

Asserting on Boolean Values

bool isNew = true;

Assert.That(isNew)                 // Pass
Assert.That(isNew, Is.True);       // Pass

bool isSaved = false;
Assert.That(isSaved == false);     // Pass
Assert.That(isSaved, Is.False);    // Pass
Assert.That(isSaved, Is.Not.True); // Pass

Asserting within Ranges

int i = 42;

Assert.That(i, Is.GreaterThan(42));          // Fail
Assert.That(i, Is.GreaterThanOrEqualTo(42)); // Pass
Assert.That(i, Is.LessThan(42));             // Fail
Assert.That(i, Is.LessThanOrEqualTo(42));    // Pass
Assert.That(i, Is.InRange(40, 50));          // Pass


DateTime d1 = new DateTime(2000, 2, 20);
DateTime d2 = new DateTime(2000, 2, 25);

Asert.That(d1, Is.EqualTo(d2)); // Fail

Asert.That(d1, Is.EqualTo(d2)
                 .Within(5)
                 .Days);        // Pass
Complete List of Asserts

Controlling Test Execution

Ignoring Tests

Use with care.

Do not comment out the test.

Use the [Ignore] attribute on the test class or method, This will give the warning icon () when the tests are ran.

[Test]
[Ignore("Most provide this text.")]
public void ReturnTermInMonths()
{
  // Arrange
  int monthsExpected= 12;
  var sut = new MortgageTerm(1);

  // Act
  var months= sut.ToMonths();

  // Assert
  Assert.That(months,
              Is.EqualTo(monthsExpected),
              "Months should be 12*number of years");
}

Organising Tests into Categories

Add a [Category{"a name"}] attribute to the test method or class.

Tests can be grouped in Test Explorer by selecting the grouping menu button and selecting Traits. This is another name for categories.

To run tests in a category right mouse click on the category icon and choose Run Selected Tests.

Build the solution to update and make sure that the category lists are up to date.

[Test]
[Category("Loan Terms")]
[Category("XYZ")]
public void ReturnTermInMonths()
{
  // Arrange
  int monthsExpected= 12;
  var sut = new MortgageTerm(1);

  // Act
  var months= sut.ToMonths();

  // Assert
  Assert.That(months,
              Is.EqualTo(monthsExpected),
              "Months should be 12 * number of years");
}

Run Specific Categories at the Command Line

dotnet test --filter "TestCategory=Loan Terms"

An Overview of the Test Execution Lifecycle

Test code is executed in the following order:

  1. Create one instance of each test class.
  2. Code in the test classes constructor.
  3. One-time setup code. Initialise data/environment/files, etc.
  4. The test methods.
    1. setup code for the method. Reset data, create sut, etc.
    2. Code in the test method.
    3. Tear down code. Clean up data, dispose of sut, etc.
  5. One-time tear down code. Clean data/environment/fields etc.
  6. The dispose method on the test class.

Running Code Before and After Each Test

namespaces Loans.Tests
{
  [Category("Product Comparison")]
  public class ProductingComparerShould
  {
    private List<MortgageProducts>_products;
    private ProductComparer sut;

    // This code is executed before each test method runs.
    [SetUp]
    public void Setup()
    {
      _product = new List<MortgageProducts>()
      {
        new MortgageProduct(1, "a", 1);
        new MortgageProduct(2, "a", 2);
        new MortgageProduct(3, "a", 3);
      };

      _sut = new ProductComparer(new LoanAmount("USD",
                                                200_000m),
                                 products);
    }

    // This code is executed after each test method runs.
    [TearDown]
    public void TearDown()
    {
        _sut.Dispose();
    }


    [Test]
    public void ReturnCorrectNumberOfComparisons()
    {
        List<MonthlyRepaymentComparision> comparisons = 
            _sut.CompareMonthlyRepayments(new MortgageTerm(30));
    }
  }
}

Running Code Before and After Each Test Class

To do this use the test method attribute [OneTimeSetUp].

// This code is executed before any test methods are ran in the test class.
[OneTimeSetUp]
public void OneTimeSetUp()
{
    // Simulate long setup initialisation time for this list of products
    // Assume that this list will not be modified during test execution.
    _product = new List<MortgageProducts>()
    {
        new MortgageProduct(1, "a", 1);
        new MortgageProduct(2, "a", 2);
        new MortgageProduct(3, "a", 3);
    };
}

// This code is executed after any test methods are ran in the test class.
[OnTimeTearDown]
public void OnTimeTearDown()
{
    _product .Clear();

    // Dispose of stuff.
}

Creating Data-Driven Tests and Reducing Test Code Duplication

Providing Method Level Test Data

[Test]
[TestCase(200_000, 6.5, 30, 1264.14)]
[TestCase(200_000, 10, 30, 1755.14)]
[TestCase(500_000, 10, 30, 4387.86]
// There are a number of overrides for this attribute.
// This method will run 3 times. One for each test case.
public void CalculateCorrectMonthlyRepayment(decimal principal,
                                             decimal interestRate,
                                             int termInYears,
                                             decimal expectedMonthlyPayment)
{
    var sut = new LoanRepaymentCalculator();

    var monthlyPayment = sut.CalculateMonthlyRepayment(
                             new LoanAmount("USD", principal),
                             interestRate,
                             new MortgageTerm(termInYears));

    // Assert
    Assert.That(monthlyPayment, Is.EqualTo(expectedMonthlyPayment));
}

Simplifying TestCase Expected Values

[Test]
[TestCase(200_000, 6.5, 30, ExpectedResult = 1264.14)]
[TestCase(200_000, 10, 30, ExpectedResult = 1755.14)]
[TestCase(500_000, 10, 30, ExpectedResult = 4387.86]
// There are a number of overrides for this attribute.
// This method will run 3 times. One for each test case.
public decimal CalculateCorrectMonthlyRepayment_Simplified(decimal principal,
                                                           decimal interestRate,
                                                           int termInYears)
{
    var sut = new LoanRepaymentCalculator();

    return sut.CalculateMonthlyRepayment(
                             new LoanAmount("USD", principal),
                             interestRate,
                             new MortgageTerm(termInYears));
}

Sharing Test Data Across Multiple Tests

Create a new class that will contain the test data.

namespace Loans.Tests
{
    public class MonthlyRepaymentTestData
    {
        public static IEnumerable TestCases
        {
            get
            {
                yield return new TestCaseData(200_000m, 6.5m, 30, 1264.14m);
                yield return new TestCaseData(500_000m, 10m, 30, 4387.86m);
            }
        }
    }
}



[Test]
[TestCaseSource(typeof(MonthlyRepaymentTestData), "TestCases")]
// There are a number of overrides for this attribute.
// This method will run 2 times. One for each test case.
public void CalculateCorrectMonthlyRepayment_Centralised(decimal principal,
                                             decimal interestRate,
                                             int termInYears,
                                             decimal expectedMonthlyPayment)
{
    var sut = new LoanRepaymentCalculator();

    var monthlyPayment = sut.CalculateMonthlyRepayment(
                             new LoanAmount("USD", principal),
                             interestRate,
                             new MortgageTerm(termInYears));

    // Assert
    Assert.That(monthlyPayment, Is.EqualTo(expectedMonthlyPayment));
}

Test Data with an Expected Return Value

namespace Loans.Tests
{
  public class MonthlyRepaymentTestData_WithReturn
  {
    public static IEnumerable TestCases
    {
      get
      {
        yield return new TestCaseData(200_000m, 6.5m, 30).Returns(1264.14m);
        yield return new TestCaseData(500_000m, 10m, 30).Returns(4387.86m);
      }
    }
  }
}



[Test]
[TestCaseSource(typeof(MonthlyRepaymentTestData_WithReturn), "TestCases")]
// There are a number of overrides for this attribute.
// This method will run 2 times. One for each test case.
public decimal CalculateCorrectMonthlyRepayment_CentralisedWithReturn(
        decimal principal,
        decimal interestRate,
        int termInYears)
{
  var sut = new LoanRepaymentCalculator();

  return sut.CalculateMonthlyRepayment(
                             new LoanAmount("USD",
                                            principal),
                             interestRate,
                             new MortgageTerm(termInYears));
}

Reading Test Data from External Source

You may want to read is a CSV data file provided by the business.

For this example:

  1. Create a text file called data.csv
  2. Add the following text
    200000, 6.5, 30, 1264.14
    200000, 10, 30, 1755.14
    200000, 10, 30, 4387.86
  3. Set the text file property Copy to Output Directory to Copy if newer.
  4. Add a new class to the Test project called MonthlyRepaymentCsvData.
  5. Add the following code to the class:
    namespace Loans.Tests
    {
      public class MonthlyRepaymentCsvData
      {
        public static IEnumerable GetTestCasess(string csvFileName)
        {
          var testCases = new List<TestCaseData>();
    
          var csvLines = System.IO.File.ReadAllLines(csvFileName);
    
          foreach (var line in csvLines)
          {
            string[] values = line.Replace(" ", "").Split(',');
    
            var principal = decimal.Parse(values[0]);
            var interestRate = decimal.Parse(values[1]);
            var termInYears = int.Parse(values[2]);
            var expectedRepayment = decimal.Parse(values[3]);
    
            testCases.Add(new TestCaseData(principal,
                                           interestRate,
                                           termInYears,
                                           expectedRepayment))
          }
    
          return testCases;
        }
      }
    }
    
  6. Add a test method that will use this test data.
    
    [Test]
    [TestCaseSource(typeof(MonthlyRepaymentCsvData), 
                           "GetTestCases",
                           new object[] { "Data.csv" })]
    // new object[] provides a list of parameters
    // for the GetTestCases method.
    // This method will run 3 times. One for each test case.
    public void CalculateCorrectMonthlyRepayment_CSV(
                        decimal principal,
                        decimal interestRate,
                        int termInYears,
                        decimal expectedMonthlyPayment)
    {
      var sut = new LoanRepaymentCalculator();
     
     var monthlyPayment = sut.CalculateMonthlyRepayment(
                               new LoanAmount("USD",
                                              principal),
                               interestRate,
                               new MortgageTerm(termInYears));
    
      // Assert
      Assert.That(monthlyPayment,
                  Is.EqualTo(expectedMonthlyPayment));
    }
    

Generating Test Data

[Test]
public void CalculateCorrectMointhlyRepayment_Combinatorial(
    [Values(100_000, 200_000, 500_000)]decimal principal,
    [Values(6.5, 10, 20)]decimal interestRate,
    [Values(10, 20, 30)]int termInYears)
{
  var sut = new LoanRepaymentCalculator();

  var monthlyPayment = sut.CalculateMonthlyRepayment(
      new LoanAmount("USD", 
                     principal),
      interestRate,
      new MortgageTerm(termInYears));
}

NUnit will create combinations of all of these values. In this example 3*3*3 = 27 test cases.

As we do not know what the expected results will be for each combination we can not add an Assert.

This is only useful to check that the sut does not throw an exception.

Using the [Sequential] Attribute to Prevent the Default Behaviour of Creating Many Combination

[Test]
[Sequential]
// This is essentially the same as having 3 TestCase attributes.
// i.e. 3 tests are executed.
public void CalculateCorrectMointhlyRepayment_Combinatorial(
    [Values(100_000, 200_000, 500_000)]decimal principal,
    [Values(6.5, 10, 20)]decimal interestRate,
    [Values(10, 20, 30)]int termInYears,
    [Values(1264.14, 1755.14, 4387.86)]decimal expectedMonthlyPayment)
{
  var sut = new LoanRepaymentCalculator();

  var monthlyPayment = sut.CalculateMonthlyRepayment(
      new LoanAmount("USD", 
                     principal),
      interestRate,
      new MortgageTerm(termInYears));

  Assert.That(monthlyPayment,
              Is.EqualTo(expectedMonthlyPayment));
}

Use the [Range] Attribute to Generate a Range of Values

[Test]
public void CalculateCorrectMointhlyRepayment_Combinatorial(
    [Range(50_000, 1_000_000, 50_000)]decimal principal,
    [Range(0.5, 20.00, 0.5)]decimal interestRate,
    [Values(10, 20, 30)]int termInYears)
{
  var sut = new LoanRepaymentCalculator();

  var monthlyPayment = sut.CalculateMonthlyRepayment(
      new LoanAmount("USD", 
                     principal),
      interestRate,
      new MortgageTerm(termInYears));
}

2400 test cases are created.

Creating Custom Category Attributes.

  1. Create a new class called ProductComparisonAttribute
  2. Add this code:
    using NUnit.Framework;
    using System;
    
    namespace Loans.Tests
    {
      [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)]
      // Because we are using a .NET framework attribute
      //We should specify how this attribute will be used. *
      // In this case we are specifying that this attribute
      // can be used at the method or | class level.
      public class ProductComparisonAttribute : CategoryAttributee
      {
      }
    }
    
    * See Pluralsight course C# Attributes: Power and Flexibility for Your Code
  3. We can now replace [Category("Product Comparison")] with [ProductComparison].

Creating Custom Constraints

If we have the same assert in multiple test methods then we can centralise the assert into a custom constraint.

  1. Add a new class to the test project called MonthlyRepaymentGreaterThanZeroConstraint.
  2. Add the code:
    using Loans.Domain.Application;
    using NUnit.Framework.Constraints;
    using System;
    
    namespace Loans.Tests
    {
      public class MonthlyRepaymentGreaterThanZeroConstraint : Constraint
      {
        public string ExpectedProductName { get; }
        public decimal ExpectedInterestRate { get; }
    
        public MonthlyRepaymentGreaterThanZeroConstraint (string expectedProductName,
                                                          decimal expectedInterestRate)
        {
          ExpectedProductName = expectedProductName;
          ExpectedInterestRate = expectedInterestRate;
        }
    
        public override ContraintResult ApplyTo<T>(T actual)
        {
          MonthlyRepaymentComparison comparison = actual as MonthlyRepaymentComparison;
    
          if (comparison is null)
          {
            return new ContraintResult(this,
                                       actual,
                                       ConstraintStatus.Error);
          }
    
          if (comparison.ProductName = ExpectedProductName
           && comparison.InterestRate == ExpectedInterestRate
           && comparison.MonthlyRepayment > 0 )
          {
            return new ConstraintResult(this,
                                        actual,
                                        ConstraintStatus.Success);
          }
    
          return new ContraintResult(this,
                                     actual,
                                     ConstraintStatus.Failure);
        }
      }
    }
    
  3. We can now replace this:
    // Assert.That(comparisons,
                   Has.Exactly()
    //                .Matches<MonthlyRepaymentComparison>(
    //                          item => item.ProductName == "a"
    //                               && item.InterestRate == 1
    //                               && item.MonthlyRepayment > 0));
    With this:
    Assert.That(comparisons,
                Has.Exactly(1)
                   .Matches(new MonthlyRepaymentGreaterThanZeroConstraint("a", 1)));
    
Copyright © 2025 delaney. All rights reserved.