简体   繁体   中英

In C#, is it possible to mock out IMessageReceiver and related classes for unit testing?

I have the following class I wish to create unit tests for:

    public class ServiceBusClient {
        private readonly IMessageReceiver messageReceiver;
        private readonly int maximumMessages;

        public ServiceBusClient(IMessageReceiver messageReceiver, int maximumMessages) {
            this.messageReceiver = messageReceiver;
            this.maximumMessages = maximumMessages;
        }

        public async Task<List<EnergyUser>> ReceiveEnergyUsersAsync() {
            List<EnergyUser> energyUsers = new List<EnergyUser>();
            List<string> lockTokens = new List<string>();
            
            this.ReceiveMessages()
                .ForEach((message) => {
                    if (message.Body != null) {
                        energyUsers.Add(JsonConvert.DeserializeObject<EnergyUser>(Encoding.UTF8.GetString(message.Body)));
                    }
                    lockTokens.Add(message.SystemProperties.LockToken);
                });

            _ = this.messageReceiver.CompleteAsync(lockTokens);
            return await Task.FromResult(energyUsers);
        }

        private List<Message> ReceiveMessages() {
            return this.messageReceiver.ReceiveAsync(this.maximumMessages)
                            .GetAwaiter()
                            .GetResult()
                            .ToList();
        }
    }

It will be observed that it is dependent upon Microsoft.Azure.ServiceBus.Core.IMessageReceiver .

My first attempt to mock this out was to use Moq . I would have expected if I create a new Mock<IMessageReceiver>() , I should be able to inject it into public ServiceBusClient(IMessageReceiver messageReceiver, int maximumMessages) , but instead the compiler tells me "Error CS1503 Argument 1: cannot convert from 'Moq.Mock<Microsoft.Azure.ServiceBus.Core.IMessageReceiver>' to 'Microsoft.Azure.ServiceBus.Core.IMessageReceiver"....

Then I thought I would try to manually mock out the class:

    internal class MockMessageReceiver : IMessageReceiver {
        public int ReceivedMaxMessgeCount { get; set; }
        public IList<Message> ReturnMessages { get; set; }
        Task<IList<Message>> IMessageReceiver.ReceiveAsync(int maxMessageCount) {
            this.ReceivedMaxMessgeCount = maxMessageCount;
            return Task.FromResult(this.ReturnMessages);
        }

        public IEnumerable<string> ReceivedLockTokens { get; set; }
        Task IMessageReceiver.CompleteAsync(IEnumerable<string> lockTokens) {
            this.ReceivedLockTokens = lockTokens;
            return Task.Delay(1);
        }

        // Many functions which do nothing just to satisfy the bloated interface.
}

This will allow me to successfully provide messages EXCEPT the messages I provide don't include SystemProperties, so ServiceBusClient will throw an error at lockTokens.Add(message.SystemProperties.LockToken) .

It turns out that the Microsoft.Azure.ServiceBus.Message implementation does not provide a setter for public SystemPropertiesCollection SystemProperties , so to set this (unless someone has a better way), I need to create my own implementation of Message:

    public class MockMessage : Message {
        public MockMessage(byte[] body) => base.Body = body;

        public new SystemPropertiesCollection SystemProperties {
            get { return this.SystemProperties; }
            set { this.SystemProperties = value; }
        }
    }

Now, I can initialize SystemPropertiesCollection, BUT the problem becomes that no property in SystemPropertiesCollection actually includes a setting, so my tests will still fail.

Then I thought: Let's create a mock for "SystemPropertiesCollection" (never mind that we are starting to swim in the dangerous waters of "too much mock".... but when I try to extend this class, my compiler complains because SystemPropertiesCollection is actually a sealed class, so I can't extend it.

So, now I'm back to square one.

Any ideas how I can create good unit tests for ServiceBusClient?

TL;DR

Ran into the same issue and solved it by utilizing reflection to gain access to private members of the class Message.SystemPropertiesCollection .

More detailed explanation

In order to avoid that message.SystemProperties.LockToken throws an InvalidOperationException you have to set the Message.SystemPropertiesCollection.SequenceNumber property to something else than -1. But the setter of that property is private, the class Message.SystemPropertiesCollection is sealed and there is no other indirect way of setting the sequence number without actually sending a message over a service bus. Which you don't want to do in a unit test.

But cheating with reflection to call the private setter of Message.SystemPropertiesCollection.SequenceNumber resolved that hurdle and let me fake Message objects that don't throw.

Sample code

This helper method generates Message objects that don't throw.

private static Message GenerateSaneMessage()
{
    var message = new Message();
    var sysCollectionType = typeof(Message.SystemPropertiesCollection);
    sysCollectionType.GetProperty(nameof(Message.SystemPropertiesCollection.SequenceNumber))!
        .SetValue(message.SystemProperties, 0);
    return message;
}

Whilst not ideal solutions I found a few suggestions in currently open GitHub issues.

One was to have a RunInternalAsync method that takes an IReceiverClient messageReceiver that calls the same implementation https://github.com/Azure/azure-functions-servicebus-extension/issues/69

Code suggested in that issue post:

// Called by the Service Bus binding.
[FunctionName("Foo")]
public Task RunAsync(
    [ServiceBusTrigger] Message serviceBusMessage,
    MessageReceiver messageReceiver) => RunInternalAsync(serviceBusMessage, messageReceiver);

// Called by unit tests.
internal Task RunInternalAsync(
    Message serviceBusMessage, 
    IReceiverClient messageReceiver { 
    // Implementation
}

I ended up going with a property that could be set in the unit test, and then you map that appropriately in your function code. As documented here: https://github.com/Azure/azure-webjobs-sdk/pull/2218#issuecomment-647584346

Sample code:

//Azure Function:
public class ServiceReportingFunction
{
    private ILogger<ServiceReportingFunction> Logger { get; }

    //Needed for unit testing until 'IMessageReceiver messageReceiver' is supported as argument in the Run Method.
    public IMessageReceiver MessageReceiver { get; set; }


    public ServiceReportingFunction(ILogger<ServiceReportingFunction> logger)
    {
        Logger = logger;
    }

    [FunctionName("ServiceReportingFunction")]
    public async Task Run([ServiceBusTrigger("%ServiceReportingQueueName%", Connection = "ServiceBusConnectionString")]Message message, MessageReceiver messageReceiver)
    {
        if (MessageReceiver == null)
            MessageReceiver = messageReceiver;

        ...
    }
}

//Unit (Xunit) test:
public class ServiceReportingFunctionTests
{
    [Fact]
    public async void Test_ServiceReportingFunction()
    {
        var logger = A.Fake<ILogger<ServiceReportingFunction>>();
        var messageReceiver = A.Fake<IMessageReceiver>();

        var function = new ServiceReportingFunction(logger);
        function.MessageReceiver = messageReceiver;
        
        ....

        await function.Run(message, null);

       ....
    }
}

This new Mock<IMessageReceiver>() will return a Mock instance. However, your constructor is expecting IMessageReceiver. That's why the compiler shows an error.

You have to get the instance by calling the Object property on the mock like this:

var serviceBusClient = new ServiceBusClient(
    new Mock<IMessageReceiver>().Object, 
    0
);

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM