Open sandboxFocusImprove this doc

Legacy testing example, step 1: Replacing hard-wired dependencies with fakes

The legacy InvoiceService calls a static database helper, the file system, the SMTP server and the clock directly. A unit test cannot run it: the test would need a database and an SMTP server, and it would wait one second for each message.

In this step, interceptors redirect these calls to fake methods. The interceptors exist only in the Test configuration, which is the build that the tests use. The source code of InvoiceService does not change. In the Test build, the calls are rewritten as follows:

Source Code
1using Metalama.Samples.LegacyTesting1;
2using System.Globalization;

3using System.Net.Mail;
4using System.Text;
5
6namespace Metalama.Samples.LegacyTesting.Tests.RedirectToFakes;
7
8// Legacy code. It calls the database, the file system, the clock and the mail server directly.
9public class InvoiceService
10{
11    private const decimal _lateFee = 25.00m;
12
13    // Sends a payment reminder for each overdue invoice of a customer, and returns a summary of the reminders.
14    public string SendReminders( int customerId )
15    {
16        var email = BillingDatabase.GetCustomerEmail( customerId );
17        var invoices = BillingDatabase.GetOverdueInvoices( customerId );
18        var template = File.ReadAllText( "reminder-template.txt" );
19
20        using var smtp = new SmtpClient();
21
22        // In production, the messages go to an SMTP server. In this example, they go to the outbox directory.
23        smtp.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory;
24        smtp.PickupDirectoryLocation = Path.GetFullPath( "outbox" );
25
26        var summary = new StringBuilder();
27
28        foreach ( var invoice in invoices )
29        {
30            // A late fee applies after 30 days.
31            var amountDue = invoice.DaysOverdue > 30 ? invoice.Amount + _lateFee : invoice.Amount;
32            var formattedAmount = amountDue.ToString( "F2", CultureInfo.InvariantCulture );
33            var daysOverdue = invoice.DaysOverdue.ToString( CultureInfo.InvariantCulture );
34
35            var body = template
36                .Replace( "{Number}", invoice.Number, StringComparison.Ordinal )
37                .Replace( "{DaysOverdue}", daysOverdue, StringComparison.Ordinal )
38                .Replace( "{Amount}", formattedAmount, StringComparison.Ordinal );
39
40            smtp.Send( "[email protected]", email, $"Payment reminder: invoice {invoice.Number}", body );
41
42            summary.AppendLine(
43                CultureInfo.InvariantCulture,
44                $"{invoice.Number}: {formattedAmount} EUR sent to {email}." );
45
46            // The SMTP server accepts at most one message per second.
47            Thread.Sleep( 1000 );
48        }
49
50        return summary.ToString();
51    }
52}
53
Transformed Code
1using Metalama.Samples.LegacyTesting1;
2using Metalama.Samples.LegacyTesting1.TestSeams;
3using System.Globalization;
4using System.Net.Mail;
5using System.Text;
6
7namespace Metalama.Samples.LegacyTesting.Tests.RedirectToFakes;
8
9// Legacy code. It calls the database, the file system, the clock and the mail server directly.
10public class InvoiceService
11{
12    private const decimal _lateFee = 25.00m;
13
14    // Sends a payment reminder for each overdue invoice of a customer, and returns a summary of the reminders.
15    public string SendReminders(int customerId)
16    {
17        var email = FakeBillingDatabase.GetCustomerEmail(customerId);
18        var invoices = FakeBillingDatabase.GetOverdueInvoices(customerId);
19        var template = FakeFileSystem.ReadAllText("reminder-template.txt");
20
21        using var smtp = new SmtpClient();
22
23        // In production, the messages go to an SMTP server. In this example, they go to the outbox directory.
24        smtp.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory;
25        smtp.PickupDirectoryLocation = Path.GetFullPath("outbox");
26
27        var summary = new StringBuilder();
28
29        foreach (var invoice in invoices)
30        {
31            // A late fee applies after 30 days.
32            var amountDue = invoice.DaysOverdue > 30 ? invoice.Amount + _lateFee : invoice.Amount;
33            var formattedAmount = amountDue.ToString("F2", CultureInfo.InvariantCulture);
34            var daysOverdue = invoice.DaysOverdue.ToString(CultureInfo.InvariantCulture);
35
36            var body = template
37                .Replace("{Number}", invoice.Number, StringComparison.Ordinal)
38                .Replace("{DaysOverdue}", daysOverdue, StringComparison.Ordinal)
39                .Replace("{Amount}", formattedAmount, StringComparison.Ordinal);
40
41            FakeMailServer.Send(smtp, "[email protected]", email, $"Payment reminder: invoice {invoice.Number}", body);
42
43            summary.AppendLine(
44                CultureInfo.InvariantCulture,
45                $"{invoice.Number}: {formattedAmount} EUR sent to {email}.");
46
47            // The SMTP server accepts at most one message per second.
48            FakeClock.Sleep(1000);
49        }
50
51        return summary.ToString();
52    }
53}
54

The fakes

Each fake is a static class with a method that has the signature of the method that it replaces. Instead of calling the dependency, the fake method reads or stores data in memory, so that a test can provide the inputs and check the outputs. For instance, the fake of SmtpClient.Send stores the messages:

1using System.Net.Mail;
2
3namespace Metalama.Samples.LegacyTesting1.TestSeams;
4
5public record SentMessage( string From, string Recipients, string? Subject, string? Body );
6
7// Replaces SmtpClient.Send in the Test configuration. It stores the messages instead of sending them.
8public static class FakeMailServer
9{
10    public static List<SentMessage> SentMessages { get; } = new();
11
12    // The first parameter receives the SmtpClient on which the legacy code calls Send. The other parameters have the
13    // names of the parameters of SmtpClient.Send, so that they receive its arguments.
14    public static void Send( SmtpClient client, string from, string recipients, string? subject, string? body )
15        => SentMessages.Add( new SentMessage( from, recipients, subject, body ) );
16
17    public static void Reset() => SentMessages.Clear();
18}
19

The other fakes are FakeBillingDatabase, FakeFileSystem and FakeClock, in the TestSeams directory. FakeClock.Sleep adds the duration to a counter instead of waiting, so that the tests run immediately.

Redirecting the calls to the fakes

The following project fabric redirects the calls:

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Code;
3using Metalama.Framework.Fabrics;
4using System.Net.Mail;
5
6namespace Metalama.Samples.LegacyTesting1.TestSeams;
7
8// Redirects the calls to the hard-wired dependencies of the legacy code to the fakes.
9internal class TestSeamsFabric : ProjectFabric
10{
11    public override void AmendProject( IProjectAmender amender )
12    {
13        // The project defines the TEST_SEAMS symbol only in the Test configuration.
14        if ( !amender.Project.PreprocessorSymbols.Contains( "TEST_SEAMS" ) )
15        {
16            return;
17        }
18
19        amender.InterceptMethods(
20            calls => calls
21                .Type( typeof(Thread) )
22                .Methods( nameof(Thread.Sleep) )
23                .Where( m => m.Parameters is [{ Type.SpecialType: SpecialType.Int32 }] )
24                .RedirectToExistingMethod( GetFake( typeof(FakeClock), nameof(FakeClock.Sleep) ) ) );
25
26        amender.InterceptMethods(
27            calls => calls
28                .Type( typeof(File) )
29                .Methods( nameof(File.ReadAllText) )
30                .Where( m => m.Parameters.Count == 1 )
31                .RedirectToExistingMethod( GetFake( typeof(FakeFileSystem), nameof(FakeFileSystem.ReadAllText) ) ) );
32
33        amender.InterceptMethods(
34            calls => calls
35                .Type( typeof(BillingDatabase) )
36                .Methods( nameof(BillingDatabase.GetCustomerEmail) )
37                .RedirectToExistingMethod(
38                    GetFake( typeof(FakeBillingDatabase), nameof(FakeBillingDatabase.GetCustomerEmail) ) ) );
39
40        amender.InterceptMethods(
41            calls => calls
42                .Type( typeof(BillingDatabase) )
43                .Methods( nameof(BillingDatabase.GetOverdueInvoices) )
44                .RedirectToExistingMethod(
45                    GetFake( typeof(FakeBillingDatabase), nameof(FakeBillingDatabase.GetOverdueInvoices) ) ) );
46
47        amender.InterceptMethods(
48            calls => calls
49                .Type( typeof(SmtpClient) )
50                .Methods( nameof(SmtpClient.Send) )
51                .Where( m => m.Parameters.Count == 4 )
52                .RedirectToExistingMethod( GetFake( typeof(FakeMailServer), nameof(FakeMailServer.Send) ) ) );
53    }
54
55    private static IMethod GetFake( Type type, string methodName )
56        => type.AsINamedType().Methods.OfName( methodName ).Single();
57}
58
  • The fabric registers the interceptors only when the TEST_SEAMS preprocessor symbol is defined. The project defines this symbol only in the Test configuration. In the other configurations, the calls are not rewritten.
  • Each InterceptMethods call selects the methods of one type, and RedirectToExistingMethod redirects their calls to a fake method. Where selects one overload: for instance, Thread.Sleep( int ) but not Thread.Sleep( TimeSpan ).
  • Metalama binds the parameters of the fake method by name. The first parameter of FakeMailServer.Send receives the SmtpClient on which the call is made. The other parameters have the names of the parameters of SmtpClient.Send, so they receive its arguments. See Interceptor binding rules.

The project file declares the Test configuration and defines the symbol:

<PropertyGroup>
    <Configurations>Debug;Release;Test</Configurations>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)'=='Test'">
    <DefineConstants>$(DefineConstants);TEST_SEAMS</DefineConstants>
</PropertyGroup>

Testing InvoiceService

The xUnit project references the legacy project with the AdditionalProperties metadata, so that it uses the Test build of this project, whatever its own configuration. Unlike the SetConfiguration metadata, AdditionalProperties is not overridden by the configuration of the solution when you build the solution:

<ProjectReference Include="..\..\legacy-testing\legacy-testing-1\Metalama.Samples.LegacyTesting1.csproj"
AdditionalProperties="Configuration=Test"/>

The tests provide the data of the database and the template file to the fakes, call InvoiceService, and check the messages, the summary and the time spent waiting:

1using Metalama.Samples.LegacyTesting1;
2using Metalama.Samples.LegacyTesting1.TestSeams;
3using Xunit;
4using Xunit.Sdk;
5using Xunit.v3;
6
7// The fakes store their state in static fields, so the tests must not run in parallel.
8[assembly: Parallelization( Mode = ParallelMode.None )]
9
10namespace Metalama.Samples.LegacyTesting1.UnitTests;
11
12public class InvoiceServiceTests
13{
14    private const int _customerId = 7;
15
16    public InvoiceServiceTests()
17    {
18        FakeClock.Reset();
19        FakeFileSystem.Reset();
20        FakeBillingDatabase.Reset();
21        FakeMailServer.Reset();
22
23        FakeFileSystem.AddFile( "reminder-template.txt", "Invoice {Number}, {DaysOverdue} days: {Amount} EUR." );
24        FakeBillingDatabase.CustomerEmails[_customerId] = "[email protected]";
25    }
26
27    [Fact]
28    public void SendsOneReminderPerOverdueInvoice()
29    {
30        FakeBillingDatabase.OverdueInvoices[_customerId] =
31            [new Invoice( "INV-1", 100.00m, 5 ), new Invoice( "INV-2", 10.00m, 20 )];
32
33        new InvoiceService().SendReminders( _customerId );
34
35        Assert.Equal(
36            [
37                new SentMessage( "[email protected]", "[email protected]", "Payment reminder: invoice INV-1",
38                    "Invoice INV-1, 5 days: 100.00 EUR." ),
39                new SentMessage( "[email protected]", "[email protected]", "Payment reminder: invoice INV-2",
40                    "Invoice INV-2, 20 days: 10.00 EUR." )
41            ],
42            FakeMailServer.SentMessages );
43    }
44
45    [Fact]
46    public void AddsLateFeeAfter30Days()
47    {
48        FakeBillingDatabase.OverdueInvoices[_customerId] =
49            [new Invoice( "INV-1", 100.00m, 30 ), new Invoice( "INV-2", 100.00m, 31 )];
50
51        var summary = new InvoiceService().SendReminders( _customerId );
52
53        Assert.Equal(
54            "INV-1: 100.00 EUR sent to [email protected]." + Environment.NewLine
55                                                         + "INV-2: 125.00 EUR sent to [email protected]." + Environment.NewLine,
56            summary );
57    }
58
59    [Fact]
60    public void WaitsOneSecondAfterEachMessage()
61    {
62        FakeBillingDatabase.OverdueInvoices[_customerId] =
63            [new Invoice( "INV-1", 100.00m, 5 ), new Invoice( "INV-2", 10.00m, 20 )];
64
65        new InvoiceService().SendReminders( _customerId );
66
67        Assert.Equal( TimeSpan.FromSeconds( 2 ), FakeClock.Elapsed );
68    }
69
70    [Fact]
71    public void SendsNothingWithoutOverdueInvoices()
72    {
73        var summary = new InvoiceService().SendReminders( _customerId );
74
75        Assert.Empty( summary );
76        Assert.Empty( FakeMailServer.SentMessages );
77    }
78}
79

The fakes store their state in static fields. Each test resets them, and the tests do not run in parallel.

Limits

The tests check the logic of InvoiceService, such as the late fee and the content of the messages. They do not test the binary that you release, and they do not test the real dependencies. Writing the fakes also requires you to know what the dependencies return. When you do not know it, record the real interactions first, as described in Legacy testing example, step 2: Recording real interactions.