To write the fakes of the previous step, you must know what the dependencies return. With legacy code, this knowledge is often lost: the database contains data that nobody fully understands, and the template file has changed since the code was written. Instead of guessing, you can record what the code does when it runs in a real environment.
In this step, a T# template records each call to the dependencies of InvoiceService: the called method, the
arguments, and the result. When the program exits, the recorder writes the calls to interactions.json. The
recording exists only in the Record configuration.
Record and replay is a common technique for tests that depend on HTTP services: for instance, the VCR library for Ruby records HTTP interactions to a file and replays them. With interceptors, you can record the calls to any method of the source code, not only HTTP requests.
Running the program with the recording
Build and run the program in the Record configuration:
dotnet run -c Record
The program writes the same summary as without the recording. The calls to Thread.Sleep and to the SMTP client are
real. The recorder writes the following file:
[
{
"Method": "BillingDatabase.GetCustomerEmail",
"Arguments": [ 42 ],
"Result": "[email protected]"
},
{
"Method": "BillingDatabase.GetOverdueInvoices",
"Arguments": [ 42 ],
"Result": [
{ "Number": "INV-1001", "Amount": 120.00, "DaysOverdue": 12 },
{ "Number": "INV-1007", "Amount": 45.50, "DaysOverdue": 41 }
]
},
{
"Method": "File.ReadAllText",
"Arguments": [ "reminder-template.txt" ],
"Result": "Dear customer,\n\nInvoice {Number} is {DaysOverdue} days overdue. The amount due is {Amount} EUR.\n\nBilling department\n"
},
{
"Method": "SmtpClient.Send",
"Arguments": [
"[email protected]",
"[email protected]",
"Payment reminder: invoice INV-1001",
"Dear customer,\n\nInvoice INV-1001 is 12 days overdue. The amount due is 120.00 EUR.\n\nBilling department\n"
],
"Result": null
},
{
"Method": "Thread.Sleep",
"Arguments": [ 1000 ],
"Result": null
}
]
The file continues with the second message and the second call to Thread.Sleep.
Implementation
The following project fabric registers the interceptors when the RECORD_INTERACTIONS symbol is defined. The project
defines this symbol only in the Record configuration.
1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Fabrics;
4using System.Net.Mail;
5
6namespace Metalama.Samples.LegacyTesting2.Recording;
7
8// Records the calls to the hard-wired dependencies of the legacy code.
9internal class RecordingFabric : ProjectFabric
10{
11 public override void AmendProject( IProjectAmender amender )
12 {
13 // The project defines the RECORD_INTERACTIONS symbol only in the Record configuration.
14 if ( !amender.Project.PreprocessorSymbols.Contains( "RECORD_INTERACTIONS" ) )
15 {
16 return;
17 }
18
19 amender.InterceptMethods(
20 calls => calls
21 .Type( typeof(Thread) )
22 .Methods( nameof(Thread.Sleep) )
23 .RedirectToSynthesizedMethod( nameof(this.Record) ) );
24
25 amender.InterceptMethods(
26 calls => calls
27 .Type( typeof(File) )
28 .Methods( nameof(File.ReadAllText) )
29 .RedirectToSynthesizedMethod( nameof(this.Record) ) );
30
31 amender.InterceptMethods(
32 calls => calls
33 .Type( typeof(BillingDatabase) )
34 .AllMethods()
35 .RedirectToSynthesizedMethod( nameof(this.Record) ) );
36
37 amender.InterceptMethods(
38 calls => calls
39 .Type( typeof(SmtpClient) )
40 .Methods( nameof(SmtpClient.Send) )
41 .RedirectToSynthesizedMethod( nameof(this.Record) ) );
42 }
43
44 [Template]
45 public dynamic? Record()
46 {
47 var interceptedMethod = meta.Interception.Method.InterceptedMethod;
48
49 var arguments = new List<object?>();
50
51 foreach ( var parameter in meta.Interception.Method.ArgumentParameters )
52 {
53 arguments.Add( parameter.Value );
54 }
55
56 var result = meta.Proceed();
57
58 InteractionRecorder.Record(
59 $"{interceptedMethod.DeclaringType.Name}.{interceptedMethod.Name}",
60 arguments,
61 result );
62
63 return result;
64 }
65}
66
RedirectToSynthesizedMethod( nameof(this.Record) )generates one interceptor for each intercepted method from theRecordtemplate. Unlike the fake methods of the previous step, one template handles all the signatures.- The template reads the arguments of the call from
meta.Interception.Method.ArgumentParameters. It calls the real method withmeta.Proceed(), and gives the method name, the arguments and the result to the recorder. - The name of the method is computed when the template is expanded, so the generated code contains it as a constant.
Metalama generates the following interceptors, in the MetalamaInterceptors class. For a method that returns void,
the result is null.
1using Metalama.Samples.LegacyTesting2;
2using System.Globalization;
3using System.Net.Mail;
4using System.Text;
5
6namespace Metalama.Samples.LegacyTesting.Tests.RecordInteractions;
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.LegacyTesting2;
2using System.Globalization;
3using System.Net.Mail;
4using System.Text;
5
6namespace Metalama.Samples.LegacyTesting.Tests.RecordInteractions;
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 = MetalamaInterceptors.BillingDatabase_GetCustomerEmail_Interceptor(customerId: customerId);
17 var invoices = MetalamaInterceptors.BillingDatabase_GetOverdueInvoices_Interceptor(customerId: customerId);
18 var template = MetalamaInterceptors.File_ReadAllText_Interceptor(path: "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 MetalamaInterceptors.SmtpClient_Send_Interceptor(smtp, from: "[email protected]", recipients: email, subject: $"Payment reminder: invoice {invoice.Number}", body: 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 MetalamaInterceptors.Thread_Sleep_Interceptor(millisecondsTimeout: 1000);
48 }
49
50 return summary.ToString();
51 }
52}
53
The recorder is ordinary run-time code. It serializes the arguments and the results with System.Text.Json, and it
writes the file when the process exits:
1using System.Text.Json;
2
3namespace Metalama.Samples.LegacyTesting2.Recording;
4
5// Stores the interactions in memory, and writes them to a JSON file when the process exits.
6public static class InteractionRecorder
7{
8 public const string FileName = "interactions.json";
9
10 private static readonly JsonSerializerOptions _options = new() { WriteIndented = true };
11 private static readonly List<Interaction> _interactions = new();
12
13 static InteractionRecorder()
14 {
15 AppDomain.CurrentDomain.ProcessExit += ( _, _ ) => Save( FileName );
16 }
17
18 public static void Record( string method, IReadOnlyList<object?> arguments, object? result )
19 {
20 var interaction = new Interaction(
21 method,
22 arguments.Select( argument => JsonSerializer.SerializeToElement( argument ) ).ToList(),
23 result == null ? null : JsonSerializer.SerializeToElement( result ) );
24
25 lock ( _interactions )
26 {
27 _interactions.Add( interaction );
28 }
29 }
30
31 public static void Save( string path )
32 {
33 lock ( _interactions )
34 {
35 File.WriteAllText( path, JsonSerializer.Serialize( _interactions, _options ) );
36 }
37 }
38}
39
Limits
- The arguments and the results must be serializable to JSON. The recorder does not record the object on which an
instance method is called, such as the
SmtpClient. - The recorder does not record exceptions. To record them, add a
try/catchblock aroundmeta.Proceed()to the template. - The recording contains the data of the environment. Review or anonymize it before you commit it.
The next step uses the recording to test InvoiceService. See Legacy testing example, step 3: Replaying interactions in characterization tests.