41. Testing Background Jobs
Testing Hangfire background jobs: unit testing job logic, mocking context, and verifying job execution
41.1 Why Test Background Jobs?
Background jobs (Chapter 29-31) are code too. They need tests:
- Job logic is complex: Often involves retries, rollback, state management
- Jobs fail silently: If untested, failures go unnoticed until production
- Hard to debug: Background jobs run outside normal request flow
- Dependencies matter: Jobs use services, databases, external APIs
- Retries can cause issues: Testing retry logic prevents double-processing
What to test:
- ✓ Job executes successfully with valid input
- ✓ Job handles errors gracefully
- ✓ Job retries on failure (Hangfire retry logic)
- ✓ Job doesn’t process twice (idempotency)
- ✓ Job updates state correctly
- ✓ Job calls dependencies correctly
41.2 Setting Up Job Tests
Install test dependencies:
dotnet add package Hangfire.Core
dotnet add package Moq
Job to test:
// Jobs/SendEmailJob.cs
using Hangfire;
using CommandAPI.Models;
using CommandAPI.Services;
using Microsoft.Extensions.Logging;
public class SendEmailJob
{
private readonly IEmailService _emailService;
private readonly ILogger<SendEmailJob> _logger;
public SendEmailJob(IEmailService emailService, ILogger<SendEmailJob> logger)
{
_emailService = emailService;
_logger = logger;
}
[Queue("default")]
[AutomaticRetry(Attempts = 3)]
public async Task SendCommandNotificationAsync(int commandId, string recipientEmail)
{
_logger.LogInformation("Sending notification for command {CommandId} to {Email}", commandId, recipientEmail);
try
{
await _emailService.SendEmailAsync(
recipientEmail,
$"Command {commandId} created",
$"Your new command with ID {commandId} has been created.");
_logger.LogInformation("Notification sent successfully for command {CommandId}", commandId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send notification for command {CommandId}", commandId);
throw; // Hangfire will retry
}
}
}
// Services/IEmailService.cs
public interface IEmailService
{
Task SendEmailAsync(string to, string subject, string body);
}
// Services/EmailService.cs
public class EmailService : IEmailService
{
private readonly ILogger<EmailService> _logger;
public EmailService(ILogger<EmailService> logger)
{
_logger = logger;
}
public async Task SendEmailAsync(string to, string subject, string body)
{
// In production, send real email via SMTP
// For testing, we'll mock this
_logger.LogInformation("Sending email to {To}: {Subject}", to, subject);
await Task.Delay(100); // Simulate network delay
}
}
41.3 Unit Testing Job Logic
Basic job test:
// Tests/JobsTests/SendEmailJobTests.cs
using Xunit;
using Moq;
using Microsoft.Extensions.Logging;
using CommandAPI.Jobs;
using CommandAPI.Services;
public class SendEmailJobTests
{
private readonly Mock<IEmailService> _mockEmailService;
private readonly Mock<ILogger<SendEmailJob>> _mockLogger;
private readonly SendEmailJob _job;
public SendEmailJobTests()
{
_mockEmailService = new Mock<IEmailService>();
_mockLogger = new Mock<ILogger<SendEmailJob>>();
_job = new SendEmailJob(_mockEmailService.Object, _mockLogger.Object);
}
[Fact]
public async Task SendCommandNotificationAsync_WithValidInput_SendsEmail()
{
// Arrange
var commandId = 1;
var recipientEmail = "user@example.com";
_mockEmailService.Setup(x => x.SendEmailAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>()))
.Returns(Task.CompletedTask);
// Act
await _job.SendCommandNotificationAsync(commandId, recipientEmail);
// Assert
_mockEmailService.Verify(
x => x.SendEmailAsync(
recipientEmail,
It.Is<string>(s => s.Contains(commandId.ToString())),
It.IsAny<string>()),
Times.Once);
}
[Fact]
public async Task SendCommandNotificationAsync_WhenEmailServiceThrows_ThrowsException()
{
// Arrange
var commandId = 1;
var recipientEmail = "user@example.com";
_mockEmailService.Setup(x => x.SendEmailAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>()))
.ThrowsAsync(new Exception("SMTP connection failed"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(
() => _job.SendCommandNotificationAsync(commandId, recipientEmail));
// Verify email service was attempted
_mockEmailService.Verify(
x => x.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Once);
}
[Fact]
public async Task SendCommandNotificationAsync_Logs_BeforeAndAfter()
{
// Arrange
var commandId = 1;
var recipientEmail = "user@example.com";
_mockEmailService.Setup(x => x.SendEmailAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>()))
.Returns(Task.CompletedTask);
// Act
await _job.SendCommandNotificationAsync(commandId, recipientEmail);
// Assert - Verify logging occurred
_mockLogger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((state, type) => state.ToString().Contains("Sending notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
_mockLogger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((state, type) => state.ToString().Contains("successfully")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
}
}
41.4 Testing Job with Dependencies
Job that uses repository:
// Jobs/GenerateCommandReportJob.cs
using Hangfire;
using CommandAPI.Data;
using CommandAPI.Services;
using Microsoft.Extensions.Logging;
public class GenerateCommandReportJob
{
private readonly ICommandRepository _repository;
private readonly IReportService _reportService;
private readonly ILogger<GenerateCommandReportJob> _logger;
public GenerateCommandReportJob(
ICommandRepository repository,
IReportService reportService,
ILogger<GenerateCommandReportJob> logger)
{
_repository = repository;
_reportService = reportService;
_logger = logger;
}
[Queue("reports")]
[AutomaticRetry(Attempts = 2)]
public async Task GenerateReportAsync(int platformId)
{
_logger.LogInformation("Generating report for platform {PlatformId}", platformId);
try
{
var commands = await _repository.GetCommandsByPlatformAsync(platformId);
if (!commands.Any())
{
_logger.LogWarning("No commands found for platform {PlatformId}", platformId);
return;
}
var reportData = new ReportData
{
PlatformId = platformId,
CommandCount = commands.Count,
GeneratedAt = DateTime.UtcNow
};
await _reportService.GenerateAndStoreAsync(reportData);
_logger.LogInformation("Report generated successfully for platform {PlatformId}", platformId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to generate report for platform {PlatformId}", platformId);
throw; // Trigger retry
}
}
}
// Services/IReportService.cs
public interface IReportService
{
Task GenerateAndStoreAsync(ReportData data);
}
public class ReportData
{
public int PlatformId { get; set; }
public int CommandCount { get; set; }
public DateTime GeneratedAt { get; set; }
}
// Test
public class GenerateCommandReportJobTests
{
private readonly Mock<ICommandRepository> _mockRepository;
private readonly Mock<IReportService> _mockReportService;
private readonly Mock<ILogger<GenerateCommandReportJob>> _mockLogger;
private readonly GenerateCommandReportJob _job;
public GenerateCommandReportJobTests()
{
_mockRepository = new Mock<ICommandRepository>();
_mockReportService = new Mock<IReportService>();
_mockLogger = new Mock<ILogger<GenerateCommandReportJob>>();
_job = new GenerateCommandReportJob(
_mockRepository.Object,
_mockReportService.Object,
_mockLogger.Object);
}
[Fact]
public async Task GenerateReportAsync_WithValidPlatform_GeneratesReport()
{
// Arrange
var platformId = 1;
var commands = new List<Command>
{
new Command { Id = 1, HowTo = "Test 1", CommandLine = "cmd1", PlatformId = platformId },
new Command { Id = 2, HowTo = "Test 2", CommandLine = "cmd2", PlatformId = platformId }
};
_mockRepository.Setup(x => x.GetCommandsByPlatformAsync(platformId))
.ReturnsAsync(commands);
_mockReportService.Setup(x => x.GenerateAndStoreAsync(It.IsAny<ReportData>()))
.Returns(Task.CompletedTask);
// Act
await _job.GenerateReportAsync(platformId);
// Assert
_mockRepository.Verify(x => x.GetCommandsByPlatformAsync(platformId), Times.Once);
_mockReportService.Verify(
x => x.GenerateAndStoreAsync(It.Is<ReportData>(r =>
r.PlatformId == platformId && r.CommandCount == 2)),
Times.Once);
}
[Fact]
public async Task GenerateReportAsync_WithNoPlatformCommands_DoesNotGenerateReport()
{
// Arrange
var platformId = 999;
_mockRepository.Setup(x => x.GetCommandsByPlatformAsync(platformId))
.ReturnsAsync(new List<Command>());
// Act
await _job.GenerateReportAsync(platformId);
// Assert
_mockRepository.Verify(x => x.GetCommandsByPlatformAsync(platformId), Times.Once);
_mockReportService.Verify(
x => x.GenerateAndStoreAsync(It.IsAny<ReportData>()),
Times.Never); // Report not generated
}
[Fact]
public async Task GenerateReportAsync_WhenRepositoryThrows_RethrowsForRetry()
{
// Arrange
var platformId = 1;
_mockRepository.Setup(x => x.GetCommandsByPlatformAsync(platformId))
.ThrowsAsync(new Exception("Database connection failed"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(
() => _job.GenerateReportAsync(platformId));
// Hangfire will catch this and retry
}
[Fact]
public async Task GenerateReportAsync_WhenReportServiceThrows_RethrowsForRetry()
{
// Arrange
var platformId = 1;
var commands = new List<Command>
{
new Command { Id = 1, HowTo = "Test", CommandLine = "cmd", PlatformId = platformId }
};
_mockRepository.Setup(x => x.GetCommandsByPlatformAsync(platformId))
.ReturnsAsync(commands);
_mockReportService.Setup(x => x.GenerateAndStoreAsync(It.IsAny<ReportData>()))
.ThrowsAsync(new Exception("Failed to store report"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(
() => _job.GenerateReportAsync(platformId));
}
}
41.5 Testing Idempotency
Jobs must be idempotent—running them multiple times produces the same result:
// Jobs/UpdateCommandStatusJob.cs
using Hangfire;
public class UpdateCommandStatusJob
{
private readonly ICommandRepository _repository;
private readonly ILogger<UpdateCommandStatusJob> _logger;
public UpdateCommandStatusJob(ICommandRepository repository, ILogger<UpdateCommandStatusJob> logger)
{
_repository = repository;
_logger = logger;
}
[AutomaticRetry(Attempts = 3)]
public async Task MarkCommandAsVerifiedAsync(int commandId)
{
_logger.LogInformation("Marking command {CommandId} as verified", commandId);
var command = await _repository.GetCommandByIdAsync(commandId);
if (command == null)
{
_logger.LogWarning("Command {CommandId} not found", commandId);
return; // Safe - job can be retried
}
if (command.Status == CommandStatus.Verified)
{
_logger.LogInformation("Command {CommandId} already verified, skipping", commandId);
return; // Idempotent - safe to run multiple times
}
command.Status = CommandStatus.Verified;
command.VerifiedAt = DateTime.UtcNow;
_repository.UpdateCommand(command);
await _repository.SaveChangesAsync();
_logger.LogInformation("Command {CommandId} marked as verified", commandId);
}
}
// Test idempotency
public class UpdateCommandStatusJobTests
{
[Fact]
public async Task MarkCommandAsVerifiedAsync_RunTwice_OnlyVerifiesOnce()
{
// Arrange
var commandId = 1;
var mockRepository = new Mock<ICommandRepository>();
var mockLogger = new Mock<ILogger<UpdateCommandStatusJob>>();
var command = new Command
{
Id = commandId,
HowTo = "Test",
CommandLine = "test",
PlatformId = 1,
Status = CommandStatus.Pending
};
mockRepository.Setup(x => x.GetCommandByIdAsync(commandId))
.ReturnsAsync(command);
mockRepository.Setup(x => x.SaveChangesAsync())
.Returns(Task.CompletedTask);
var job = new UpdateCommandStatusJob(mockRepository.Object, mockLogger.Object);
// Act - Run twice
await job.MarkCommandAsVerifiedAsync(commandId);
// Command is now verified
command.Status = CommandStatus.Verified;
mockRepository.Setup(x => x.GetCommandByIdAsync(commandId))
.ReturnsAsync(command);
await job.MarkCommandAsVerifiedAsync(commandId);
// Assert - SaveChanges called only once
mockRepository.Verify(x => x.SaveChangesAsync(), Times.Once);
}
}
public enum CommandStatus
{
Pending,
Verified
}
41.6 Testing Retry Behavior
While Hangfire handles retry logic, you can test your retry strategy:
// Jobs/ResilienceAwareJob.cs
using Hangfire;
using Polly;
public class ResilienceAwareJob
{
private readonly IExternalApiService _apiService;
private readonly ILogger<ResilienceAwareJob> _logger;
public ResilienceAwareJob(IExternalApiService apiService, ILogger<ResilienceAwareJob> logger)
{
_apiService = apiService;
_logger = logger;
}
[AutomaticRetry(Attempts = 5, DelaysInSeconds = new[] { 10, 60, 300, 900, 3600 })]
public async Task FetchAndStoreDataAsync()
{
_logger.LogInformation("Starting data fetch job");
// Use Polly retry policy within the job
var retryPolicy = Policy
.Handle<HttpRequestException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt)), // Exponential backoff
onRetry: (exception, duration) =>
{
_logger.LogWarning(exception, "Retrying after {Duration}", duration);
});
try
{
await retryPolicy.ExecuteAsync(async () =>
{
var data = await _apiService.GetDataAsync();
_logger.LogInformation("Data fetched successfully: {DataCount} items", data.Count);
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Job failed after all retries");
throw; // Hangfire will retry with its own delays
}
}
}
// Service interface
public interface IExternalApiService
{
Task<List<DataItem>> GetDataAsync();
}
public class DataItem { }
// Test
public class ResilienceAwareJobTests
{
[Fact]
public async Task FetchAndStoreDataAsync_WithTemporaryFailure_RetriesAndSucceeds()
{
// Arrange
var mockApiService = new Mock<IExternalApiService>();
var mockLogger = new Mock<ILogger<ResilienceAwareJob>>();
var callCount = 0;
// First call fails, second succeeds (simulating temporary failure)
mockApiService.Setup(x => x.GetDataAsync())
.Returns(() =>
{
callCount++;
if (callCount == 1)
{
return Task.FromException<List<DataItem>>(
new HttpRequestException("Service unavailable"));
}
return Task.FromResult(new List<DataItem> { new DataItem() });
});
var job = new ResilienceAwareJob(mockApiService.Object, mockLogger.Object);
// Act
await job.FetchAndStoreDataAsync();
// Assert
mockApiService.Verify(x => x.GetDataAsync(), Times.Exactly(2));
}
[Fact]
public async Task FetchAndStoreDataAsync_WithPersistentFailure_EventuallyThrows()
{
// Arrange
var mockApiService = new Mock<IExternalApiService>();
var mockLogger = new Mock<ILogger<ResilienceAwareJob>>();
mockApiService.Setup(x => x.GetDataAsync())
.ThrowsAsync(new HttpRequestException("Service permanently down"));
var job = new ResilienceAwareJob(mockApiService.Object, mockLogger.Object);
// Act & Assert
await Assert.ThrowsAsync<HttpRequestException>(
() => job.FetchAndStoreDataAsync());
}
}
41.7 Testing Long-Running Jobs
Jobs that take a long time:
// Jobs/ProcessLargeDatasetJob.cs
using Hangfire;
public class ProcessLargeDatasetJob
{
private readonly IDataProcessingService _processingService;
private readonly ILogger<ProcessLargeDatasetJob> _logger;
public ProcessLargeDatasetJob(
IDataProcessingService processingService,
ILogger<ProcessLargeDatasetJob> logger)
{
_processingService = processingService;
_logger = logger;
}
[Queue("longrunning")]
[JobDisplayName("Process {0} for {1}")]
[AutomaticRetry(Attempts = 1)] // Don't retry long-running jobs
public async Task ProcessDatasetAsync(string datasetId, IJobCancellationToken cancellationToken)
{
_logger.LogInformation("Processing dataset {DatasetId}", datasetId);
try
{
// Check cancellation periodically
if (cancellationToken.ShutdownToken.IsCancellationRequested)
{
_logger.LogInformation("Job cancelled for dataset {DatasetId}", datasetId);
return;
}
var result = await _processingService.ProcessAsync(datasetId, cancellationToken.ShutdownToken);
_logger.LogInformation("Dataset {DatasetId} processed successfully: {RecordCount} records",
datasetId, result.ProcessedCount);
}
catch (OperationCanceledException)
{
_logger.LogWarning("Job cancelled for dataset {DatasetId}", datasetId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process dataset {DatasetId}", datasetId);
throw;
}
}
}
public interface IDataProcessingService
{
Task<ProcessResult> ProcessAsync(string datasetId, CancellationToken cancellationToken);
}
public class ProcessResult
{
public int ProcessedCount { get; set; }
}
// Test
public class ProcessLargeDatasetJobTests
{
[Fact]
public async Task ProcessDatasetAsync_WithValidDataset_ProcessesSuccessfully()
{
// Arrange
var datasetId = "dataset-123";
var mockService = new Mock<IDataProcessingService>();
var mockLogger = new Mock<ILogger<ProcessLargeDatasetJob>>();
mockService.Setup(x => x.ProcessAsync(datasetId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult { ProcessedCount = 1000 });
var job = new ProcessLargeDatasetJob(mockService.Object, mockLogger.Object);
var cancellationToken = new CancellationToken();
// Act
await job.ProcessDatasetAsync(
datasetId,
new JobCancellationToken(CancellationToken.None));
// Assert
mockService.Verify(
x => x.ProcessAsync(datasetId, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ProcessDatasetAsync_WhenCancelled_StopsProcessing()
{
// Arrange
var datasetId = "dataset-123";
var mockService = new Mock<IDataProcessingService>();
var mockLogger = new Mock<ILogger<ProcessLargeDatasetJob>>();
var cts = new CancellationTokenSource();
mockService.Setup(x => x.ProcessAsync(datasetId, It.IsAny<CancellationToken>()))
.Callback(() => cts.Cancel())
.ThrowsAsync(new OperationCanceledException());
var job = new ProcessLargeDatasetJob(mockService.Object, mockLogger.Object);
// Act & Assert - Should not throw, just log cancellation
await job.ProcessDatasetAsync(
datasetId,
new JobCancellationToken(cts.Token));
}
}
public class JobCancellationToken : IJobCancellationToken
{
public CancellationToken ShutdownToken { get; }
public JobCancellationToken(CancellationToken shutdownToken)
{
ShutdownToken = shutdownToken;
}
}
public interface IJobCancellationToken
{
CancellationToken ShutdownToken { get; }
}
41.8 Testing with Hangfire Test Server
For more realistic testing, use Hangfire’s test server:
using Hangfire;
using Hangfire.InMemory;
public class HangfireIntegrationTests
{
[Fact]
public async Task Job_EnqueuedAndExecuted_Completes()
{
// Arrange
var storage = new InMemoryStorage();
var jobClient = new BackgroundJobClient(storage);
var server = new BackgroundJobServer(storage);
var mockEmailService = new Mock<IEmailService>();
mockEmailService.Setup(x => x.SendEmailAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>()))
.Returns(Task.CompletedTask);
// Act - Enqueue job
var jobId = jobClient.Enqueue(() => Console.WriteLine("Test job"));
// Assert
Assert.NotNull(jobId);
}
}
41.9 Best Practices for Job Testing
1. Always test success and failure paths:
[Fact]
public async Task Job_WithValidInput_Succeeds() { }
[Fact]
public async Task Job_WhenDependencyThrows_Throws() { }
[Fact]
public async Task Job_WhenDependencyThrows_LogsError() { }
2. Verify logging for debugging:
_mockLogger.Verify(
x => x.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((state, type) => state.ToString().Contains("expected message")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
3. Test idempotency to prevent double-processing:
// Run job twice, verify idempotent behavior
await job.DoWorkAsync();
await job.DoWorkAsync();
// Verify only one change was made
4. Use meaningful test names:
[Fact]
public async Task SendEmailJob_WithValidEmail_SendsSuccessfully() { }
[Fact]
public async Task SendEmailJob_WhenSmtpThrows_RetriesAndThrows() { }
41.10 Running Job Tests
# Run all job tests
dotnet test --filter "JobTests"
# Run specific job test
dotnet test --filter "SendEmailJobTests"
# Run with verbose output
dotnet test -v d --filter "GenerateCommandReportJobTests"
# Watch mode
dotnet watch test
41.11 Integration Testing Jobs with WebApplicationFactory
Combine job testing with integration testing:
public class JobIntegrationTests : IClassFixture<CommandsApiFactory>
{
private readonly HttpClient _client;
public JobIntegrationTests(CommandsApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task CreateCommand_EnqueuesNotificationJob()
{
// Arrange
var createDto = new CommandMutateDto
{
HowTo = "Test",
CommandLine = "test",
PlatformId = 1
};
// Act
var response = await _client.PostAsJsonAsync("/api/commands", createDto);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
// Verify job was enqueued (check Hangfire storage or mock job client)
}
}
41.12 What’s Next
You now have:
- ✓ Understanding why background jobs need testing
- ✓ Unit testing job logic with mocked dependencies
- ✓ Testing jobs with repositories
- ✓ Testing idempotency (running jobs multiple times)
- ✓ Testing retry behavior with Polly
- ✓ Testing long-running jobs with cancellation
- ✓ Testing with Hangfire in-memory storage
- ✓ Best practices for job testing
- ✓ Integration testing with WebApplicationFactory
Next: Deployment & DevOps—Docker, containerization, and CI/CD pipelines.