Search code examples
c#dependency-injectionasp.net-core-2.0hangfire

Mocking Hangfire RecurringJob Dependency in .Net Core 2


Consider the following controller:

public class SubmissionController : Controller
{ 
    public SubmissionController()
    { }

    public IActionResult Post()
    {
        RecurringJob.AddOrUpdate(() => InitiateSubmission(), Cron.Minutely);

        return Ok("Periodic submission triggered");
    }
}

Does Hangfire offer an abstraction inject a dependency for RecurringJob class? I have done some research and the only available abstraction is IBackgroundJobClient, which does not have the option to schedule a recurring job.

I need to verify that the job has been added in a unit test.


Solution

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