Turn untestable legacy code into comprehensive test suites in minutes
SpecRec helps you test legacy code by recording real method calls and replaying them as test doubles. Here's the complete workflow:
Replace direct instantiation (new) with Create<> to make dependencies controllable:
// Before: Hard dependency
var emailService = new EmailService(connectionString);
// After: Testable dependency
using static SpecRec.GlobalObjectFactory;
var emailService = Create<IEmailService, EmailService>(connectionString);Create a test that uses SpecRec's Context API to automatically record and verify interactions:
[Theory]
[SpecRecLogs]
public async Task UserRegistration(Context ctx, string email = "john@example.com", string name = "John Doe")
{
await ctx
.Substitute<IEmailService>("π§")
.Substitute<IDatabaseService>("ποΈ")
.Verify(async () =>
{
// Run your legacy code
var userService = new UserService();
return userService.RegisterNewUser(email, name);
});
}Control how exceptions are handled during verification:
// Default (0): Log exceptions, continue test
await ctx.Verify(testMethod);
// Skip logging exceptions
await ctx.Verify(testMethod, exceptions: ExceptionHandling.SkipVerify);
// Rethrow exceptions after processing
await ctx.Verify(testMethod, exceptions: ExceptionHandling.PassThrough);
// Print stacktrace to stderr
await ctx.Verify(testMethod, exceptions: ExceptionHandling.Trace);
// Combine flags
await ctx.Verify(testMethod, exceptions: ExceptionHandling.PassThrough + ExceptionHandling.Trace);First run generates a .received.txt file with <missing_value> placeholders:
π§ SendWelcomeEmail:
πΈ recipient: "john@example.com"
πΈ subject: "Welcome!"
πΉ Returns: <missing_value>
Replace <missing_value> with actual values and save as .verified.txt:
The next run will stop at the next missing return value:
π§ SendWelcomeEmail:
πΈ recipient: "john@example.com"
πΈ subject: "Welcome!"
πΉ Returns: True
ποΈ CreateUser:
πΈ email: "john@example.com"
πΉ Returns: <missing_value>
Repeat until the test passes! SpecRec's Parrot replays these exact return values whenever your code calls these methods.
Parrot is SpecRec's intelligent test double that:
- Reads your verified specification files
- Matches incoming method calls by name and parameters
- Returns the exact values you specified
This means you never have to manually set up mocks - just provide the return values once and Parrot handles the rest.
SpecRec relies on ObjectFactory for dependency injection.
Standalone dependency injection for legacy code. This is a separate package that can be used independently of SpecRec if you only need to make legacy code testable without the full recording/replay features.
<PackageReference Include="ObjectFactory" Version="1.0.0" />Full testing framework with recording, replay, and automatic test double generation. Depends on ObjectFactory.
<PackageReference Include="SpecRec" Version="3.0.0" />Note: When you install SpecRec, ObjectFactory is included automatically as a dependency.
Add to your test project:
<PackageReference Include="SpecRec" Version="1.0.1" />
<PackageReference Include="Verify.Xunit" Version="26.6.0" />Or via Package Manager Console:
Install-Package SpecRec
Install-Package Verify.XunitUse Case: Your legacy code creates dependencies with new, making it impossible to inject test doubles.
Solution: Replace new with Create<> to enable dependency injection without major refactoring.
[Theory]
[SpecRecLogs]
public async Task MyTest(Context ctx)
{
await ctx
.Substitute<IRepository>("ποΈ")
.Verify(async () =>
{
// Your code can now use:
var repo = Create<IRepository>(); // Gets the test double
});
}[Fact]
public void RegularTest()
{
// Setup
ObjectFactory.Instance().ClearAll();
var mockRepo = new MockRepository();
ObjectFactory.Instance().SetOne<IRepository>(mockRepo);
// Act - your code calls Create<IRepository>() and gets mockRepo
var result = myService.ProcessData();
// Assert
Assert.Equal(expected, result);
// Cleanup
ObjectFactory.Instance().ClearAll();
}Transform hard dependencies into testable code:
// Legacy code with hard dependency
class UserService
{
public void ProcessUser(int id)
{
var repo = new SqlRepository("server=prod;...");
var user = repo.GetUser(id);
// ...
}
}
// Testable code using ObjectFactory
using static SpecRec.GlobalObjectFactory;
class UserService
{
public void ProcessUser(int id)
{
var repo = Create<IRepository, SqlRepository>("server=prod;...");
var user = repo.GetUser(id);
// ...
}
}Use Case: You need to understand what your legacy code actually does - what it calls, with what parameters, and what it expects back.
Solution: CallLogger records all method calls to create human-readable specifications.
[Theory]
[SpecRecLogs]
public async Task RecordInteractions(Context ctx)
{
await ctx.Verify(async () =>
{
// Wraps services to log all calls automatically
ctx.Wrap<IEmailService>(realEmailService, "π§");
// Run your code - all calls are logged
var result = await ProcessEmails();
return result;
});
}[Fact]
public async Task RecordManually()
{
var logger = new CallLogger();
var wrapped = logger.Wrap<IEmailService>(emailService, "π§");
// Use wrapped service
wrapped.SendEmail("user@example.com", "Hello");
// Verify the log
await Verify(logger.SpecBook.ToString());
}CallLogger produces readable specifications including exception recording:
π§ SendEmail:
πΈ to: "user@example.com"
πΈ subject: "Hello"
πΉ Returns: True
π§ GetPendingEmails:
πΈ maxCount: 10
πΉ Returns: ["email1", "email2"]
π§ SendBulkEmail:
πΈ recipients: ["user1@example.com", "user2@example.com"]
π» Throws: InvalidOperationException("Rate limit exceeded")
Use Case: You have recorded interactions and now want to replay them as test doubles without manually setting up mocks.
Solution: Parrot reads verified files and automatically provides the right return values.
Note: Exception replay works best with simple exceptions that have writable properties and string constructors. Exceptions with read-only properties, complex constructors, or special initialization may not reproduce exactly - see Exception Reproduction Limitations for details.
[Theory]
[SpecRecLogs]
public async Task ReplayWithParrot(Context ctx)
{
await ctx
.Substitute<IEmailService>("π§")
.Substitute<IUserService>("π€")
.Verify(async () =>
{
// Your code gets Parrots that replay from verified file
var result = ProcessUserFlow();
return result;
});
}[Fact]
public async Task ManualParrot()
{
var callLog = CallLog.FromVerifiedFile();
var parrot = new Parrot(callLog);
var emailService = parrot.Create<IEmailService>("π§");
var userService = parrot.Create<IUserService>("π€");
// Use parrots as test doubles
var result = ProcessWithServices(emailService, userService);
// Verify all expected calls were made
await Verify(callLog.ToString());
}Use Case: Your methods pass around complex objects that are hard to serialize in specifications.
Solution: Register objects with IDs to show clean references instead of verbose dumps.
[Theory]
[SpecRecLogs]
public async Task TrackObjects(Context ctx)
{
await ctx
.Register(new DatabaseConfig { /* ... */ }, "dbConfig")
.Substitute<IDataService>("ποΈ")
.Verify(async () =>
{
// When logged, shows as <id:dbConfig> instead of full dump
var service = Create<IDataService>();
service.Initialize(ctx.GetRegistered<DatabaseConfig>("dbConfig")); // Logs as <id:dbConfig>
});
}[Fact]
public void TrackManually()
{
var factory = ObjectFactory.Instance();
var config = new DatabaseConfig();
// Register object with ID
factory.Register(config, "myConfig");
var logger = new CallLogger();
var wrapped = logger.Wrap<IService>(service, "π§");
// Call logs show <id:myConfig> instead of serialized object
wrapped.Process(config);
}Use Case: You want to test multiple scenarios with the same setup but different data.
Solution: SpecRecLogs automatically discovers verified files and creates a test for each.
For a test method TestUserScenarios, create multiple verified files:
TestClass.TestUserScenarios.AdminUser.verified.txtTestClass.TestUserScenarios.RegularUser.verified.txtTestClass.TestUserScenarios.GuestUser.verified.txt
Each becomes a separate test case.
Tests can accept parameters from verified files:
[Theory]
[SpecRecLogs]
public async Task TestWithData(Context ctx, string userName, bool isAdmin = false)
{
await ctx
.Substitute<IUserService>("π€")
.Verify(async () =>
{
var service = Create<IUserService>();
var result = service.CreateUser(userName, isAdmin);
return $"Created: {userName} (Admin: {isAdmin})";
});
}Verified file with parameters:
π <Test Inputs>
πΈ userName: "alice"
πΈ isAdmin: True
π€ CreateUser:
πΈ name: "alice"
πΈ isAdmin: True
πΉ Returns: 123
Created: alice (Admin: True)
Hide sensitive data or control output:
public class MyService : IMyService
{
public void ProcessSecret(string public, string secret)
{
CallLogFormatterContext.IgnoreArgument(1); // Hide secret parameter
// ...
}
public string GetToken()
{
CallLogFormatterContext.IgnoreReturnValue(); // Hide return value
return "secret-token";
}
}Use CallLogFormatterContext.LoggedReturnValue<T>() to access parsed return values from verified files within your manual stubs.
public class ManualEmailServiceStub : IEmailService
{
public bool SendEmail(string to, string subject)
{
// Your custom logic here
Console.WriteLine($"Sending email to {to}: {subject}");
// Return the value from verified specification file
return CallLogFormatterContext.LoggedReturnValue<bool>();
}
public List<string> GetPendingEmails()
{
// Custom processing logic
ProcessPendingQueue();
// Return parsed value from specification, with fallback
return CallLogFormatterContext.LoggedReturnValue<List<string>>() ?? new List<string>();
}
}Use with verified specification files:
π§ SendEmail:
πΈ to: "user@example.com"
πΈ subject: "Welcome!"
πΉ Returns: True
π§ GetPendingEmails:
πΉ Returns: ["email1@test.com", "email2@test.com"]
[Theory]
[SpecRecLogs]
public async Task TestWithManualStub(Context ctx)
{
await ctx.Verify(async () =>
{
// Register your manual stub instead of auto-generated parrot
var customStub = new ManualEmailServiceStub();
ctx.Substitute<IEmailService>("π§", customStub);
var service = Create<IEmailService>();
var result = service.SendEmail("user@example.com", "Welcome!");
return result; // Returns True from verified file
});
}Track how objects are constructed:
public class EmailService : IEmailService, IConstructorCalledWith
{
public void ConstructorCalledWith(ConstructorParameterInfo[] parameters)
{
// Access constructor parameters
// parameters[0].Name, parameters[0].Value, etc.
}
}SpecRec enforces strict formatting in verified files:
- Strings: Must use quotes:
"hello" - Booleans: Case-sensitive:
TrueorFalse - DateTime: Format
yyyy-MM-dd HH:mm:ss:2023-12-25 14:30:45 - Arrays: No spaces:
[1,2,3]or["a","b","c"] - Objects: Use IDs:
<id:myObject> - Null: Lowercase:
null
SpecRec can record and replay most exceptions, but some exceptions may not reproduce exactly due to .NET's exception design:
- Standard exceptions with string constructors:
ArgumentException,InvalidOperationException,NotSupportedException - Custom exceptions with writable properties and simple constructors
- Exceptions that store state in public, writable properties
Read-only Message Property
// Some exceptions don't allow message changes after construction
// Result: Default message instead of your custom messageNo String Constructor
// Exceptions without Exception(string message) constructor
// Result: Created with parameterless constructor, custom message lostComplex Constructors Only
// ArgumentException(string message, string paramName) requires two parameters
// Result: Falls back to generic Exception with prefixed message like "[Original: ArgumentException] your message"Read-only or Private State
// SqlException with complex internal state, FileNotFoundException with file system details
// Result: Exception created but internal collections/state not reproducedMissing Inner Exceptions
// Exception chains with InnerException are not supported in current format
// Result: Only outer exception reproduced, chain brokenAlways Lost Information
- Stack traces (exceptions are created fresh during replay)
- Source file/line information
- Thread context and timing information
For complex scenarios, consider using simpler custom exceptions or implementing manual stubs with CallLogFormatterContext.LoggedReturnValue<T>().
- .NET 9.0+
- C# 13+
- xUnit (for examples) - any test framework works
- Verify framework (for approval testing)
PolyForm Noncommercial License 1.0.0
