SpecRec 2.2.5

dotnet add package SpecRec --version 2.2.5
                    
NuGet\Install-Package SpecRec -Version 2.2.5
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SpecRec" Version="2.2.5" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SpecRec" Version="2.2.5" />
                    
Directory.Packages.props
<PackageReference Include="SpecRec" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SpecRec --version 2.2.5
                    
#r "nuget: SpecRec, 2.2.5"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SpecRec@2.2.5
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SpecRec&version=2.2.5
                    
Install as a Cake Addin
#tool nuget:?package=SpecRec&version=2.2.5
                    
Install as a Cake Tool

SpecRec for .NET

Turn untestable legacy code into comprehensive test suites in minutes

Spec Rec Logo

Introduction: From Legacy Code to Tests in 3 Steps

SpecRec helps you test legacy code by recording real method calls and replaying them as test doubles. Here's the complete workflow:

Step 1: Break Dependencies with Create<>

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);

Step 2: Write a Test with ctx.Verify

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);
        });
}
Exception Handling Options

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);

Step 3: Run Test and Fill Return Values

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.

Understanding Parrot

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.

Installation

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.Xunit

Core Components

ObjectFactory: Making Dependencies Testable

Use 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
        });
}
In Regular Tests
[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();
}
Breaking Dependencies

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);
        // ...
    }
}

CallLogger: Recording Interactions

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.

With Context API
[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;
    });
}
In Regular Tests
[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());
}
Specification Format

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")

Parrot: Replaying Interactions

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.

With Context API
[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;
        });
}
In Regular Tests
[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());
}

Object ID Tracking

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.

With Context API
[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>
        });
}
In Regular Tests
[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);
}

SpecRecLogs Attribute: Data-Driven Testing

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.

File Structure

For a test method TestUserScenarios, create multiple verified files:

  • TestClass.TestUserScenarios.AdminUser.verified.txt
  • TestClass.TestUserScenarios.RegularUser.verified.txt
  • TestClass.TestUserScenarios.GuestUser.verified.txt

Each becomes a separate test case.

With Parameters

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)

Advanced Features

Controlling What Gets Logged

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";
    }
}
Manual Test Doubles with LoggedReturnValue

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
    });
}
Constructor Parameter Tracking

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.
    }
}
Type-Safe Value Parsing

SpecRec enforces strict formatting in verified files:

  • Strings: Must use quotes: "hello"
  • Booleans: Case-sensitive: True or False
  • 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

Exception Reproduction Limitations

SpecRec can record and replay most exceptions, but some exceptions may not reproduce exactly due to .NET's exception design:

Exceptions That Reproduce Well

  • Standard exceptions with string constructors: ArgumentException, InvalidOperationException, NotSupportedException
  • Custom exceptions with writable properties and simple constructors
  • Exceptions that store state in public, writable properties

Exceptions That May Not Reproduce Correctly

Read-only Message Property

// Some exceptions don't allow message changes after construction
// Result: Default message instead of your custom message

No String Constructor

// Exceptions without Exception(string message) constructor
// Result: Created with parameterless constructor, custom message lost

Complex 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 reproduced

Missing Inner Exceptions

// Exception chains with InnerException are not supported in current format
// Result: Only outer exception reproduced, chain broken

Always 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>().

Requirements

  • .NET 9.0+
  • C# 13+
  • xUnit (for examples) - any test framework works
  • Verify framework (for approval testing)

License

PolyForm Noncommercial License 1.0.0

Product Compatible and additional computed target framework versions.
.NET net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.2.5 273 9/16/2025
2.2.4 272 9/16/2025
2.2.0 268 9/16/2025
2.1.0 163 8/31/2025
2.0.0 181 8/28/2025
1.0.6 271 8/25/2025
1.0.5 269 8/25/2025
1.0.4 269 8/25/2025
1.0.3 200 8/24/2025
1.0.2 197 8/24/2025