Open sandboxFocusImprove this doc

Legacy testing example, step 2: Recording real interactions

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 the Record template. 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 with meta.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.

Source Code
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
Transformed Code
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/catch block around meta.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.