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:
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
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_SEAMSpreprocessor symbol is defined. The project defines this symbol only in theTestconfiguration. In the other configurations, the calls are not rewritten. - Each
InterceptMethodscall selects the methods of one type, andRedirectToExistingMethodredirects their calls to a fake method.Whereselects one overload: for instance,Thread.Sleep( int )but notThread.Sleep( TimeSpan ). - Metalama binds the parameters of the fake method by name. The first parameter of
FakeMailServer.Sendreceives theSmtpClienton which the call is made. The other parameters have the names of the parameters ofSmtpClient.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.