Skip to content

devill/specrec-csharp

Repository files navigation

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.

Packages

SpecRec relies on ObjectFactory for dependency injection.

ObjectFactory (1.0.0)

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

SpecRec (3.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.

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.

With Context API (Recommended for SpecRec Tests)

[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

About

Automated Legacy Testing Tools for .NET

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •