Michael Feathers, in Working Effectively with Legacy Code (Prentice Hall, 2004), defines legacy code as code without tests. Such code is risky to change, because nothing detects a regression. However, to add tests, you usually need to change the code first, for instance to replace a call to the database with a call to an interface that a test can implement. This series shows how to get such code under test without editing it.
The legacy code of this series is an InvoiceService class. It sends a payment reminder for each overdue invoice of
a customer. It calls its dependencies directly:
BillingDatabase.GetCustomerEmailandBillingDatabase.GetOverdueInvoices, the methods of a static helper that queries the database,File.ReadAllText, to read the template of the message,SmtpClient.Send, to send the message,Thread.Sleep, to respect the rate limit of the SMTP server.
1using System.Globalization;
2using System.Net.Mail;
3using System.Text;
4
5namespace Metalama.Samples.LegacyTesting1;
6
7// Legacy code. It calls the database, the file system, the clock and the mail server directly.
8public class InvoiceService
9{
10 private const decimal _lateFee = 25.00m;
11
12 // Sends a payment reminder for each overdue invoice of a customer, and returns a summary of the reminders.
13 public string SendReminders( int customerId )
14 {
15 var email = BillingDatabase.GetCustomerEmail( customerId );
16 var invoices = BillingDatabase.GetOverdueInvoices( customerId );
17 var template = File.ReadAllText( "reminder-template.txt" );
18
19 using var smtp = new SmtpClient();
20
21 // In production, the messages go to an SMTP server. In this example, they go to the outbox directory.
22 smtp.DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory;
23 smtp.PickupDirectoryLocation = Path.GetFullPath( "outbox" );
24
25 var summary = new StringBuilder();
26
27 foreach ( var invoice in invoices )
28 {
29 // A late fee applies after 30 days.
30 var amountDue = invoice.DaysOverdue > 30 ? invoice.Amount + _lateFee : invoice.Amount;
31 var formattedAmount = amountDue.ToString( "F2", CultureInfo.InvariantCulture );
32 var daysOverdue = invoice.DaysOverdue.ToString( CultureInfo.InvariantCulture );
33
34 var body = template
35 .Replace( "{Number}", invoice.Number, StringComparison.Ordinal )
36 .Replace( "{DaysOverdue}", daysOverdue, StringComparison.Ordinal )
37 .Replace( "{Amount}", formattedAmount, StringComparison.Ordinal );
38
39 smtp.Send( "[email protected]", email, $"Payment reminder: invoice {invoice.Number}", body );
40
41 summary.AppendLine(
42 CultureInfo.InvariantCulture,
43 $"{invoice.Number}: {formattedAmount} EUR sent to {email}." );
44
45 // The SMTP server accepts at most one message per second.
46 Thread.Sleep( 1000 );
47 }
48
49 return summary.ToString();
50 }
51}
52
A test of this code would need a database and an SMTP server, and it would wait one second for each message.
Seams
Feathers defines a seam as a place where you can alter the behavior of a program without editing it in that place.
Each seam has an enabling point: the place where you decide which behavior to use. In object-oriented code, the most
common seam is a virtual method or an interface, and the enabling point is the code that creates the object. Code
that calls static methods directly, such as InvoiceService, has no such seam.
A Metalama interceptor is a seam at each call site. The build rewrites the calls to the selected methods so that they call another method, and the source code of the calls does not change. The enabling point is a fabric, which registers the interceptors only in some build configurations. For the comparable seams of other languages, see the link seams and preprocessing seams of Feathers. Martin Fowler describes the same idea in the article Legacy Seam.
With these seams, you can apply a safe workflow to legacy code:
- Get the code under test without changing it, with fakes or with recorded interactions.
- Pin its current behavior with characterization tests. Feathers defines a characterization test as a test that describes the actual behavior of the code, and not the behavior that the code should have. This technique is also known as golden master testing or approval testing. In .NET, libraries such as Verify and ApprovalTests implement it.
- Refactor the code toward real abstractions, while the tests detect regressions.
- Remove the interceptors.
In this series
| Article | Description |
|---|---|
| Legacy testing example, step 1: Replacing hard-wired dependencies with fakes | In the Test configuration, interceptors redirect the calls to existing fake methods. An xUnit project tests InvoiceService. |
| Legacy testing example, step 2: Recording real interactions | A T# template records each call, with its arguments and its result, to a JSON file while the application runs. |
| Legacy testing example, step 3: Replaying interactions in characterization tests | A T# template replays the recorded results, and a snapshot test pins the output of InvoiceService. |
Limits of the approach
- The tests do not test the binary that you release. In the
Testconfiguration, the build rewrites the calls to the dependencies, and the release build does not. The tests prove the behavior of the code around these calls, but not of the calls themselves. Keep the difference small: intercept only the calls to the dependencies that the tests cannot use. - Metalama intercepts only the calls in the source code of the current project. It does not intercept the calls in other assemblies, for instance the calls that a library makes internally, or in the output of source generators.
- Interceptors intercept only the uses of methods. They do not intercept property accesses such as
DateTime.Now, constructors such asnew SmtpClient(), operators, orawaitexpressions. In this example, the constructor ofSmtpClientand the assignments of its properties still run in the tests. This is acceptable because they do not connect to the server. Code that readsDateTime.Nowneeds another seam, for instance aTimeProvider, before you can test it. - The fakes and the replayer store their state in static fields, so the tests cannot run in parallel.
- A recording contains the data of the environment in which you made it. When you record in production, the recording can contain personal data. Review or anonymize it before you commit it.
- A replay test checks the order of the calls and their arguments. A refactoring that changes the order of the calls breaks the test, even when the behavior of the program does not change. Use replay tests to pin the behavior before the refactoring, and replace them with ordinary unit tests afterward.
- Metalama supports only SDK-style projects. An old-style project file must be converted to the SDK style before you can use interceptors. This conversion changes the project file, not the code. Projects that target .NET Framework are supported. See Requirements.
- The
meta.Interceptionproperty, which the templates of steps 2 and 3 use, is a C# 14 extension property. In a project that uses an earlier version of C#, usemeta.GetExtension<IInterceptionInfo>()instead. - The
Metalama.Extensions.Interceptorspackage requires a Metalama Community license or higher.
Next step: refactoring and removing the interceptors
Interceptors are a temporary step. When the tests pin the behavior of InvoiceService, refactor it toward real
abstractions: inject an IBillingRepository, a file provider, an IEmailSender and a TimeProvider instead of
calling the static methods. The tests then use test doubles of these abstractions, and they test the binary that
you release. When no code calls the intercepted methods anymore, delete the fabric and the fakes. The calls are no
longer rewritten, and the Test configuration is no longer needed.