If you check the source code of RecurringJob
class, you will see that its static methods result in call to RecurringJobManager
class:
public static class RecurringJob
{
private static readonly Lazy<RecurringJobManager> Instance = new Lazy<RecurringJobManager>(
() => new RecurringJobManager());
// ...
public static void AddOrUpdate(
Expression<Action> methodCall,
string cronExpression,
TimeZoneInfo timeZone = null,
string queue = EnqueuedState.DefaultQueue)
{
var job = Job.FromExpression(methodCall);
var id = GetRecurringJobId(job);
Instance.Value.AddOrUpdate(id, job, cronExpression, timeZone ?? TimeZoneInfo.Utc, queue);
}
// ...
}
RecurringJobManager
implements IRecurringJobManager
interface which you could use for dependency injection and mock in UT.
However RecurringJob
has internal logic for getting a job from lambda and building a job id:
var job = Job.FromExpression(methodCall);
var id = GetRecurringJobId(job);
Job.FromExpression()
is a public method that you can safely use. However GetRecurringJobId
is a private method defined as following:
private static string GetRecurringJobId(Job job)
{
return $"{job.Type.ToGenericTypeString()}.{job.Method.Name}";
}
GetRecurringJobId
basically returns name of job method in form of SubmissionController.InitiateSubmission
. It's based on internal class TypeExtensions
with extension methods for Type
. You can't use this class directly since it is internal, so you should duplicate that logic.
If you follow this approach your final solution would be:
TypeExtensions (copied from Hangfire sources):
static class TypeExtensions
{
public static string ToGenericTypeString(this Type type)
{
if (!type.GetTypeInfo().IsGenericType)
{
return type.GetFullNameWithoutNamespace()
.ReplacePlusWithDotInNestedTypeName();
}
return type.GetGenericTypeDefinition()
.GetFullNameWithoutNamespace()
.ReplacePlusWithDotInNestedTypeName()
.ReplaceGenericParametersInGenericTypeName(type);
}
private static string GetFullNameWithoutNamespace(this Type type)
{
if (type.IsGenericParameter)
{
return type.Name;
}
const int dotLength = 1;
// ReSharper disable once PossibleNullReferenceException
return !String.IsNullOrEmpty(type.Namespace)
? type.FullName.Substring(type.Namespace.Length + dotLength)
: type.FullName;
}
private static string ReplacePlusWithDotInNestedTypeName(this string typeName)
{
return typeName.Replace('+', '.');
}
private static string ReplaceGenericParametersInGenericTypeName(this string typeName, Type type)
{
var genericArguments = type.GetTypeInfo().GetAllGenericArguments();
const string regexForGenericArguments = @"`[1-9]\d*";
var rgx = new Regex(regexForGenericArguments);
typeName = rgx.Replace(typeName, match =>
{
var currentGenericArgumentNumbers = int.Parse(match.Value.Substring(1));
var currentArguments = string.Join(",", genericArguments.Take(currentGenericArgumentNumbers).Select(ToGenericTypeString));
genericArguments = genericArguments.Skip(currentGenericArgumentNumbers).ToArray();
return string.Concat("<", currentArguments, ">");
});
return typeName;
}
public static Type[] GetAllGenericArguments(this TypeInfo type)
{
return type.GenericTypeArguments.Length > 0 ? type.GenericTypeArguments : type.GenericTypeParameters;
}
}
RecurringJobManagerExtensions:
public static class RecurringJobManagerExtensions
{
public static void AddOrUpdate(this IRecurringJobManager manager, Expression<Action> methodCall, Func<string> cronExpression, TimeZoneInfo timeZone = null, string queue = EnqueuedState.DefaultQueue)
{
var job = Job.FromExpression(methodCall);
var id = $"{job.Type.ToGenericTypeString()}.{job.Method.Name}";
manager.AddOrUpdate(id, job, cronExpression(), timeZone ?? TimeZoneInfo.Utc, queue);
}
}
Controller with injected IRecurringJobManager
:
public class SubmissionController : Controller
{
private readonly IRecurringJobManager recurringJobManager;
public SubmissionController(IRecurringJobManager recurringJobManager)
{
this.recurringJobManager = recurringJobManager;
}
public IActionResult Post()
{
recurringJobManager.AddOrUpdate(() => InitiateSubmission(), Cron.Minutely);
return Ok("Periodic submission triggered");
}
public void InitiateSubmission()
{
// ...
}
}
Well, this approach will work, but I'm not a fan of it. It's based on some internal Hangfire stuff that could be changed in the future.
That's why I suggest to use another approach. You could add new facade interface (e.g. IRecurringJobFacade
) which will mimic methods from RecurringJob
that you are going to use. Implementation of this interface will just call corresponding RecurringJob
methods. Then you inject this IRecurringJobFacade
into the controller and could easily mock it in UT. Here is a sample:
IRecurringJobFacade:
public interface IRecurringJobFacade
{
void AddOrUpdate(Expression<Action> methodCall, Func<string> cronExpression);
// Mimic other methods from RecurringJob that you are going to use.
// ...
}
RecurringJobFacade:
public class RecurringJobFacade : IRecurringJobFacade
{
public void AddOrUpdate(Expression<Action> methodCall, Func<string> cronExpression)
{
RecurringJob.AddOrUpdate(methodCall, cronExpression);
}
}
Controller with injected IRecurringJobFacade
:
public class SubmissionController : Controller
{
private readonly IRecurringJobFacade recurringJobFacade;
public SubmissionController(IRecurringJobFacade recurringJobFacade)
{
this.recurringJobFacade = recurringJobFacade;
}
public IActionResult Post()
{
recurringJobFacade.AddOrUpdate(() => InitiateSubmission(), Cron.Minutely);
return Ok("Periodic submission triggered");
}
public void InitiateSubmission()
{
// ...
}
}
As you see this approach is much simpler and most importantly it's much more reliable, since it does not dig into Hangfire internals and just calls RecurringJob
methods as usual.
Such facade interface is often used when code could not be mocked directly (static methods or classes not based on interfaces). Some other examples that I have used in my practice: mock of System.IO.File
, DateTime.Now
, System.Timers.Timer
, etc.