SpecRec - Automated Legacy Testing Tools for .NET
$ dotnet add package SpecRecTurn 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.Verify(async () =>
{
// Set up automatic test doubles
ctx.Substitute<IEmailService>("📧")
.Substitute<IDatabaseService>("🗃️");
// Run your legacy code
var userService = new UserService();
return userService.RegisterNewUser(email, name);
});
}
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:
This means you never have to manually set up mocks - just provide the return values once and Parrot handles the rest.
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.Verify(async () =>
{
// Automatically injects test doubles for Create<IRepository>
ctx.Substitute<IRepository>("🗄️");
// 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:
📧 SendEmail:
🔸 to: "user@example.com"
🔸 subject: "Hello"
🔹 Returns: True
📧 GetPendingEmails:
🔸 maxCount: 10
🔹 Returns: ["email1", "email2"]
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.
[Theory]
[SpecRecLogs]
public async Task ReplayWithParrot(Context ctx)
{
await ctx.Verify(async () =>
{
// Automatically creates Parrots from verified file
ctx.Substitute<IEmailService>("📧")
.Substitute<IUserService>("👤");
// 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.Verify(async () =>
{
var complexConfig = new DatabaseConfig { /* ... */ };
// Register with an ID
ctx.Register(complexConfig, "dbConfig");
// When logged, shows as <id:dbConfig> instead of full dump
ctx.Substitute<IDataService>("🗃️");
var service = Create<IDataService>();
service.Initialize(complexConfig); // 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.txtEach 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.Verify(async () =>
{
ctx.Substitute<IUserService>("👤");
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";
}
}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:
"hello"True or Falseyyyy-MM-dd HH:mm:ss: 2023-12-25 14:30:45[1,2,3] or ["a","b","c"]<id:myObject>nullPolyForm Noncommercial License 1.0.0