Local Job Testing
This guide explains how to unit-test your job implementations locally. The Milvasoft.Milvaion.Sdk.Worker package lets you execute any job and inspect its result without a running RabbitMQ, Redis, or database.
Why Test Locally?
When you develop a new job in your worker, the typical deployment cycle looks like this:
Code → Build → Docker image → Deploy to Dev → Activate job → Trigger manually → Check logs
This cycle is slow and requires a live environment for every iteration. Local testing short-circuits this by running the exact same job execution path directly in a test process:
| Local Test | Live Environment | |
|---|---|---|
| RabbitMQ | Not required | Required |
| Redis | Not required | Required |
| Database | Not required | Required |
| Milvaion API | Not required | Required |
| Feedback speed | Milliseconds | Minutes |
| Debugger | Full support | Not available |
| Result inspection | Programmatic | Dashboard only |
Tip: Local tests do not replace live testing in a Dev environment. Use them to iterate on business logic fast, then validate end-to-end in Dev before promoting.
Template — Pre-configured Test Project
If you created your worker from the Milvaion Worker Template, a test project is already included. You don't need to create or configure anything.
The template generates the following structure out of the box:
MyWorker/
├── MyWorker.csproj ← your worker
├── MyJobs.cs
├── Program.cs
└── MyWorker.Tests/
├── MyWorker.Tests.csproj ← test project (pre-configured)
└── SampleJobTests.cs ← ready-to-run example tests
The test project already has:
xUnit,FluentAssertions, andMicrosoft.NET.Test.Sdkreferenced- A
ProjectReferencepointing to your worker - Sample tests for every job type included in the template
To verify everything works right after scaffolding, just run:
dotnet test MyWorker.Tests
All sample tests should pass immediately with no additional setup.
If you used the template, skip to JobTestRunner API and start writing tests for your own jobs.
Setup
1. Create a Test Project
Add an xUnit test project alongside your worker:
dotnet new xunit -n MyWorker.Tests
cd MyWorker.Tests
2. Add References
Add the testing SDK and a reference to your worker project:
dotnet add reference ../MyWorker/MyWorker.csproj
dotnet add package Milvasoft.Milvaion.Sdk.Worker
dotnet add package FluentAssertions
Your .csproj should look like:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsTestProject>true</IsTestProject>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Milvasoft.Milvaion.Sdk.Worker" Version="10.1.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyWorker\MyWorker.csproj" />
</ItemGroup>
</Project>
JobTestRunner API
JobTestRunner is a fluent builder that wraps JobExecutor — the same component that runs your jobs in production.
Builder Methods
| Method | Description | Default |
|---|---|---|
For(IJobBase job) | Sets the job instance to run | — |
WithJobData<T>(T data) | Serializes data as JSON and passes it as job data | {} |
WithJobData(string json) | Sets raw JSON string as job data | {} |
WithTimeout(int seconds) | Sets execution timeout; 0 disables timeout | 30 |
WithWorkerId(string id) | Sets the worker ID in the execution context | "local-test" |
WithCancellationToken(CancellationToken) | Passes a cancellation token | None |
WithLoggerFactory(ILoggerFactory) | Captures logs through a custom logger | Console |
All methods return the builder, so they can be chained freely.
Return Value: JobExecutionResult
RunAsync() returns a JobExecutionResult record with the following fields:
| Field | Type | Description |
|---|---|---|
Status | JobOccurrenceStatus | Completed, Failed, Cancelled, TimedOut |
DurationMs | long | Wall-clock time of the job in milliseconds |
Exception | string | Exception message and inner exception chain (if any) |
Result | string | Serialized return value for IJobWithResult<T> jobs |
Logs | List<OccurrenceLog> | All log entries written via context.Log*() |
IsPermanentFailure | bool | true if a PermanentJobException was thrown |
CorrelationId | Guid | Correlation ID of this test run |
Basic Usage
Simplest Test
[Fact]
public async Task MyJob_ShouldComplete_WhenRunNormally()
{
var result = await JobTestRunner
.For(new MyJob())
.RunAsync();
result.Status.Should().Be(JobOccurrenceStatus.Completed);
result.Exception.Should().BeNull();
}
With Typed Job Data
When your job reads typed data via context.GetData<T>(), pass it with WithJobData<T>():
[Fact]
public async Task SendInvoiceJob_ShouldComplete_WhenDataIsValid()
{
var jobData = new InvoiceJobData
{
CustomerId = 42,
InvoiceId = 1001,
Currency = "USD"
};
var result = await JobTestRunner
.For(new SendInvoiceJob())
.WithJobData(jobData)
.WithTimeout(60)
.RunAsync();
result.Status.Should().Be(JobOccurrenceStatus.Completed);
result.Exception.Should().BeNull();
result.DurationMs.Should().BeGreaterThan(0);
}
With Raw JSON
When you want full control over the raw payload:
[Fact]
public async Task MyJob_ShouldHandleMissingFields_Gracefully()
{
var result = await JobTestRunner
.For(new MyJob())
.WithJobData("""{"customerId": 99}""")
.RunAsync();
result.Status.Should().Be(JobOccurrenceStatus.Completed);
}