msgraph-sdk-dotnet icon indicating copy to clipboard operation
msgraph-sdk-dotnet copied to clipboard

v5 How do I unit test my code when using Graph API

Open Steve887 opened this issue 1 year ago • 19 comments

V5 of the Azure Graph API has removed the IGraphServiceClient and has methods that are not virtual. This makes mocking calls to the Graph API client extremely difficult as you have to mock the underlying Request Handler code (https://github.com/microsoftgraph/msgraph-sdk-dotnet/issues/1667).

Is there a recommended way to Mock calls to the GraphServiceClient in order for me to check my logic calling several Graph API methods.

For example, I have code like:

var userResponse = await _graphClient.Users.GetAsync(requestConfiguration =>
{
    requestConfiguration.QueryParameters.Select = new[] { "Id", "AccountEnabled" };
    requestConfiguration.QueryParameters.Filter = $"UserPrincipalName eq '{loginName}'";
    requestConfiguration.QueryParameters.Top = 1;
});

var azureUser = userResponse.Value.First();
if (azureUser.AccountEnabled == false)
{
    azureUser.AccountEnabled = true;
    await _graphClient.Users[azureUser.Id].PatchAsync(azureUser);
}

How would I go about mocking the GetAsync and PatchAsync methods?

Steve887 avatar Mar 20 '23 06:03 Steve887

Thanks for raising this @Steve887

To help understand this better. Do you wish to be able to mock the invocation of the PatchAsync method? And that the mocking of the IRequestAdapter interface that all methods use would not be suitable in your scenario?

andrueastman avatar Mar 23 '23 15:03 andrueastman

It's more general than that. Mocking IRequestAdapter seems quite difficult and unintuitive. It would be better to mock the Patch or Get directly.

For example, if I mocked IRequestAdapter how would I Assert that Patch was called? Or set a specific return value for the Get?

Steve887 avatar Mar 24 '23 00:03 Steve887

I face a similar issue with Mock using IRequestAdapter, it doesn't work for 2nd invocation of graphclient.

here's method image

MockTestCase image

gitdj avatar Mar 25 '23 23:03 gitdj

image

In this line: It.IsAny<ParsableFactory<Microsoft.Graph.Models.User>>()

Microsoft.Graph.Models.User is model from MSGraph API to return, which can be returned in ReturnAsync method. However I agree that it will be nice to find better way for this :)

konri1990 avatar Apr 04 '23 14:04 konri1990

Far from ideal, but I'm using above examples and some data from RequestInformation to set up my mocks for more specific cases. Otherwise I ran into conflicts with multiple setups that return different values.

RequestAdapterMock.Setup(m => m.SendAsync(
                    It.Is<RequestInformation>(information => information.HttpMethod == Method.GET
                                                             && information.UrlTemplate.Contains("/users/")
                                                             && information.PathParameters.Values.Contains(user.Id)),
                    It.IsAny<ParsableFactory<User>>(),
                    It.IsAny<Dictionary<string,ParsableFactory<IParsable>>>(),
                    It.IsAny<CancellationToken>()))
                .ReturnsAsync(user);

I matched my specific uses with the Graph API reference and used values from the RequestInformation object.

I think the GraphServiceClient should be made more testable though. I don't see the rationale behind removing the virtual methods, this complicates things quite a lot. Of course we can create our own abstractions, but isn't it quite a common use case to want to mock this client?

ysbakker avatar Apr 14 '23 13:04 ysbakker

Currently I'm using this approach. First a helper method for the request adapter:

    public static class RequestAdapterMockFactory
    {
        public static Mock<IRequestAdapter> Create(MockBehavior mockBehavior = MockBehavior.Strict)
        {
            var mockSerializationWriterFactory = new Mock<ISerializationWriterFactory>();
            mockSerializationWriterFactory.Setup(factory => factory.GetSerializationWriter(It.IsAny<string>()))
                .Returns((string _) => new JsonSerializationWriter());

            var mockRequestAdapter = new Mock<IRequestAdapter>(mockBehavior);
            // The first path element must have four characters to mimic v1.0 or beta
            // This is especially needed to mock batch requests.
            mockRequestAdapter.SetupGet(adapter => adapter.BaseUrl).Returns("http://graph.test.internal/mock");
            mockRequestAdapter.SetupSet(adapter => adapter.BaseUrl = It.IsAny<string>());
            mockRequestAdapter.Setup(adapter => adapter.EnableBackingStore(It.IsAny<IBackingStoreFactory>()));
            mockRequestAdapter.SetupGet(adapter => adapter.SerializationWriterFactory).Returns(mockSerializationWriterFactory.Object);

            return mockRequestAdapter;
        }
    }

And here the setup for the call:

var mockRequestAdapter = RequestAdapterMockFactory.Create();
var graphServiceClient = new GraphServiceClient(mockRequestAdapter.Object);

mockRequestAdapter.Setup(adapter => adapter.SendAsync(
    // Needs to be correct HTTP Method of the desired method 👇🏻
    It.Is<RequestInformation>(info => info.HttpMethod == Method.GET),
    // 👇🏻 Needs to be method from object type that will be returned from the SDK method.
    Microsoft.Graph.Models.User.CreateFromDiscriminatorValue,
    It.IsAny<Dictionary<string, ParsableFactory<IParsable>>>(), It.IsAny<CancellationToken>()))
    .ReturnsAsync(new Microsoft.Graph.Models.User
    {
        DisplayName = "Hello World",
    });

As already mentioned by others, this is not easy and makes testing more complicated then before when we had an IGraphServiceClient, but at least it works.

olivermue avatar Apr 25 '23 06:04 olivermue

I always use NSubstitute and xUnit. So I created some samples how you could test. It's not very straight forward but it works! Hopefully it will help some people.

[Fact]
public async Task Microsoft_Graph_All_Users()
{
    var requestAdapter = Substitute.For<IRequestAdapter>();
    GraphServiceClient graphServiceClient = new GraphServiceClient(requestAdapter);

    var usersMock = new UserCollectionResponse()
    {
        Value = new List<User> {
            new User()
            {
                Id = Guid.NewGuid().ToString(),
                GivenName = "John",
                Surname = "Doe"
            },
            new User()
            {
                Id = Guid.NewGuid().ToString(),
                GivenName = "Jane",
                Surname = "Doe"
            }
        }
    };

    requestAdapter.SendAsync(
        Arg.Any<RequestInformation>(),
        Arg.Any<ParsableFactory<UserCollectionResponse>>(),
        Arg.Any<Dictionary<string, ParsableFactory<IParsable>>>(),
        Arg.Any<CancellationToken>())
        .ReturnsForAnyArgs(usersMock);

    var users = await graphServiceClient.Users.GetAsync();
    Assert.Equal(2, users.Value.Count);
}


[Fact]
public async Task Microsoft_Graph_User_By_Id()
{
    var requestAdapter = Substitute.For<IRequestAdapter>();
    GraphServiceClient graphServiceClient = new GraphServiceClient(requestAdapter);

    var johnObjectId = Guid.NewGuid().ToString();
    var janeObjectId = Guid.NewGuid().ToString();

    var userJohn = new User()
    {
        Id = johnObjectId,
        GivenName = "John",
        Surname = "Doe"
    };

    var userJane = new User()
    {
        Id = janeObjectId,
        GivenName = "Jane",
        Surname = "Doe"
    };

    requestAdapter.SendAsync(
        Arg.Is<RequestInformation>(ri => ri.PathParameters.Values.Contains(johnObjectId)),
        Arg.Any<ParsableFactory<Microsoft.Graph.Models.User>>(),
        Arg.Any<Dictionary<string, ParsableFactory<IParsable>>>(),
        Arg.Any<CancellationToken>())
            .Returns(userJohn);

    requestAdapter.SendAsync(
        Arg.Is<RequestInformation>(ri => ri.PathParameters.Values.Contains(janeObjectId)),
        Arg.Any<ParsableFactory<Microsoft.Graph.Models.User>>(),
        Arg.Any<Dictionary<string, ParsableFactory<IParsable>>>(),
        Arg.Any<CancellationToken>())
            .Returns(userJane);

    var userResultJohn = await graphServiceClient.Users[johnObjectId].GetAsync();
    Assert.Equal("John", userResultJohn.GivenName);

    var userResultJane = await graphServiceClient.Users[janeObjectId].GetAsync();
    Assert.Equal("Jane", userResultJane.GivenName);
}

[Fact]
public async Task Microsoft_Graph_User_By_GivenName()
{
    var requestAdapter = Substitute.For<IRequestAdapter>();
    GraphServiceClient graphServiceClient = new GraphServiceClient(requestAdapter);

    var johnObjectId = Guid.NewGuid().ToString();
    var janeObjectId = Guid.NewGuid().ToString();

    var userJohn = new User()
    {
        Id = johnObjectId,
        GivenName = "John",
        Surname = "Doe"
    };

    var userJane = new User()
    {
        Id = janeObjectId,
        GivenName = "Jane",
        Surname = "Doe"
    };

    requestAdapter.SendAsync(
        Arg.Is<RequestInformation>(ri => ri.QueryParameters["%24filter"].ToString() == "givenName='John'"),
        Arg.Any<ParsableFactory<UserCollectionResponse>>(),
        Arg.Any<Dictionary<string, ParsableFactory<IParsable>>>(),
        Arg.Any<CancellationToken>())
            .Returns(new UserCollectionResponse()
            {
                Value = new List<User>() { userJohn }
            });

    requestAdapter.SendAsync(
        Arg.Is<RequestInformation>(ri => ri.QueryParameters["%24filter"].ToString() == "givenName='Jane'"),
        Arg.Any<ParsableFactory<UserCollectionResponse>>(),
        Arg.Any<Dictionary<string, ParsableFactory<IParsable>>>(),
        Arg.Any<CancellationToken>())
            .Returns(new UserCollectionResponse()
            {
                Value = new List<User>() { userJane }
            });

    var userResultJohn = await graphServiceClient.Users.GetAsync(rc =>
    {
        rc.QueryParameters.Filter = "givenName='John'";
    });
    Assert.NotNull(userResultJohn);
    Assert.Equal(1, userResultJohn.Value.Count);
    Assert.Equal("John", userResultJohn.Value.First().GivenName);

    var userResultJane = await graphServiceClient.Users.GetAsync(rc =>
    {
        rc.QueryParameters.Filter = "givenName='Jane'";
    });
    Assert.NotNull(userResultJane);
    Assert.Equal(1, userResultJane.Value.Count);
    Assert.Equal("Jane", userResultJane.Value.First().GivenName);
}

LockTar avatar Jun 09 '23 13:06 LockTar

If you want some guidance on how to write unit tests for in example Post calls and to check the contents of your call. You can follow this thread: https://github.com/microsoft/kiota/issues/2767

LockTar avatar Jun 27 '23 09:06 LockTar

Thank you @LockTar your examples really saved me some headaches.

obsad1an avatar Sep 04 '23 11:09 obsad1an

Thanks for all the examples, helped me a lot. One tip I want to share:

            mockSerializationWriterFactory.Setup(factory => factory.GetSerializationWriter(It.IsAny<string>()))
                .Returns(new JsonSerializationWriter());

should be replaced with

            mockSerializationWriterFactory.Setup(factory => factory.GetSerializationWriter(It.IsAny<string>()))
                .Returns(() => new JsonSerializationWriter());

in case you want to invoke a PostAsync multiple times. More context can be found here: https://github.com/microsoft/kiota/issues/2767

timvandesteeg avatar Nov 05 '23 22:11 timvandesteeg

Hey @LockTar, these examples are great.

Any idea on how to access nested resources and override their response values.

Specifically I am after the user/{userId}/drive endpoint

compdesigner-nz avatar Feb 14 '24 01:02 compdesigner-nz

@compdesigner-nz The good news is for nested resources the request adapter only gets called once for the leaf resource (in your case: Drive). As you can see in @LockTar 's Microsoft_Graph_User_By_Id, there's a RequestInformation object that's passed to RequestAdapter that contains all of the IDs of all indexed parent resources.

The following steps describe how to figure out how to mock out this call: graphServiceClient.Users[johnObjectId].Drive

  1. Find the builder of the first resource, e.g. Users. You can see all the different builder types here. It's the UsersRequestBuilder.
  2. In UsersRequestBuilder, look for the index method. Notice when you index users, the code adds an entry to the PathParameters object where the key is "user%2Did" and the value is the user id, i.e., johnObjectId in our example.
  3. That then passes all the info down to UserItemRequestBuilder, so check that out and look for the Drive property. That gives us a DriveRequestBuilder.
  4. We've now arrived at what we call GetAsync() on, so the return type is Microsoft.Graph.Models.Drive.
  5. Now we can write some code (aDriveToReturn is your expected return value for the test):
  requestAdapter.SendAsync(
        Arg.Is<RequestInformation>(ri => ri.PathParameters.Any((keyValuePair) => keyValuePair.Key == "user%2Did" && keyValuePair.Value == johnObjectId)),
        Arg.Any<ParsableFactory<Microsoft.Graph.Models.Drive>>(),
        Arg.Any<Dictionary<string, ParsableFactory<IParsable>>>(),
        Arg.Any<CancellationToken>())
            .Returns(aDriveToReturn);

(replace with mocking library of your choice) Note that we mock SendAsync, since that's what GetAsync() calls on the RequestAdapter.

Note that I haven't run this, but have some similar code that works. If there are any issues, please post back so I can correct them.

yagni avatar Feb 16 '24 00:02 yagni

Ah! Fantastic, thanks so much @yagni! That works perfectly 🙏🏼

compdesigner-nz avatar Feb 16 '24 04:02 compdesigner-nz

Hey @yagni, another question:

What about the /content endpoint on drives[driveId]/items[itemId]? It says that this returns a 302 and a preauthenticated URL. I assume the response type is still DriveItem and that is fed as an argument under Arg.Any<ParsableFactory<DriveItem>>()?

I see that the /content endpoint defined under here defines a parameter key of ?%24format*. I assume I add a new conditional under my ri fluent expression to check to see if a key in QueryParameters exists with that name? From there it's a matter of overriding the return value...?

Correct?

compdesigner-nz avatar Feb 19 '24 03:02 compdesigner-nz

The format parameter is only needed, if you like to download a file in a specific format, that maybe differs from the original format. If you don't set this parameter you'll get back the file as-is from the drive. For more details about this auto conversions take a look at the documentation.

Also be aware, depending on your usage of the api, you don't need to explicitly call the /content endpoint to retrieve a url to the file content. When you request an item via its id you also get back the property @microsoft.graph.downloadUrl which is the very same url as under content. For more details take a look at the documentation.

For your mocking (or production) code this means, that you have to take this url and request it via a simple HttpClient (which you have to mock too) to get the content. Depending on the file size or you needs you can either request the whole file or byte ranges. For more details, take a look at the documentation.

olivermue avatar Feb 19 '24 06:02 olivermue

RequestAdapterMock.Setup(m => m.SendAsync(
                    It.Is<RequestInformation>(information => information.HttpMethod == Method.GET
                                                             && information.UrlTemplate.Contains("/users/")
                                                             && information.PathParameters.Values.Contains(user.Id)),

Using Moq, I tried this method from @ysbakker but compiler complains about cannot convert from RequestInformation to Action<Microsoft.Graph.Users.Item.UserItemRequestBuilder.UserItemRequestBuilderGetRequestConfiguration>. So, this was the best I could come up with to get working. This needs to be fixed; unit testing code written using this SDK is a nightmare.

graphServiceClientMock?.Setup(_ => _.Users[It.IsAny<string>()].GetAsync(
        It.IsAny<Action<Microsoft.Graph.Users.Item.UserItemRequestBuilder.UserItemRequestBuilderGetRequestConfiguration>>(), default)).ReturnsAsync(user);

kfwalther avatar Feb 21 '24 20:02 kfwalther

It's more general than that. Mocking IRequestAdapter seems quite difficult and unintuitive. It would be better to mock the Patch or Get directly.

For example, if I mocked IRequestAdapter how would I Assert that Patch was called? Or set a specific return value for the Get?

It looks like you'd assert patch using the RequestInformation.HttpMethod provided to your mock. Of course, that's still not exactly intuitive.

yagni avatar Feb 21 '24 20:02 yagni

RequestAdapterMock.Setup(m => m.SendAsync(
                    It.Is<RequestInformation>(information => information.HttpMethod == Method.GET
                                                             && information.UrlTemplate.Contains("/users/")
                                                             && information.PathParameters.Values.Contains(user.Id)),

Using Moq, I tried this method from @ysbakker but compiler complains about cannot convert from RequestInformation to Action<Microsoft.Graph.Users.Item.UserItemRequestBuilder.UserItemRequestBuilderGetRequestConfiguration>. So, this was the best I could come up with to get working. This needs to be fixed; unit testing code written using this SDK is a nightmare.

graphServiceClientMock?.Setup(_ => _.Users[It.IsAny<string>()].GetAsync(
        It.IsAny<Action<Microsoft.Graph.Users.Item.UserItemRequestBuilder.UserItemRequestBuilderGetRequestConfiguration>>(), default)).ReturnsAsync(user);

@kfwalther are you sure you're on v5? It looks like you're using the earlier style of mocking, back when GraphServiceClient implemented an interface that had a bunch of virtual methods you could mock.

yagni avatar Feb 21 '24 20:02 yagni

RequestAdapterMock.Setup(m => m.SendAsync(
                    It.Is<RequestInformation>(information => information.HttpMethod == Method.GET
                                                             && information.UrlTemplate.Contains("/users/")
                                                             && information.PathParameters.Values.Contains(user.Id)),

Using Moq, I tried this method from @ysbakker but compiler complains about cannot convert from RequestInformation to Action<Microsoft.Graph.Users.Item.UserItemRequestBuilder.UserItemRequestBuilderGetRequestConfiguration>. So, this was the best I could come up with to get working. This needs to be fixed; unit testing code written using this SDK is a nightmare.

graphServiceClientMock?.Setup(_ => _.Users[It.IsAny<string>()].GetAsync(
        It.IsAny<Action<Microsoft.Graph.Users.Item.UserItemRequestBuilder.UserItemRequestBuilderGetRequestConfiguration>>(), default)).ReturnsAsync(user);

There seems to be something wrong elsewhere in your code, this example code works like expected and I added a few comment where most of the time the error you described occurs:

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Graph;
using Microsoft.Graph.Models;
using Microsoft.Kiota.Abstractions;
using Microsoft.Kiota.Abstractions.Serialization;
using Microsoft.Kiota.Abstractions.Store;
using Microsoft.Kiota.Serialization.Json;
using Moq;

public static class RequestAdapterMockFactory
{
	public static Mock<IRequestAdapter> Create(MockBehavior mockBehavior = MockBehavior.Strict)
	{
		var mockSerializationWriterFactory = new Mock<ISerializationWriterFactory>();
		mockSerializationWriterFactory.Setup(factory => factory.GetSerializationWriter(It.IsAny<string>()))
			.Returns((string _) => new JsonSerializationWriter());

		var mockRequestAdapter = new Mock<IRequestAdapter>(mockBehavior);
		mockRequestAdapter.SetupGet(adapter => adapter.BaseUrl).Returns("http://test.internal");
		mockRequestAdapter.SetupSet(adapter => adapter.BaseUrl = It.IsAny<string>());
		mockRequestAdapter.Setup(adapter => adapter.EnableBackingStore(It.IsAny<IBackingStoreFactory>()));
		mockRequestAdapter.SetupGet(adapter => adapter.SerializationWriterFactory).Returns(mockSerializationWriterFactory.Object);

		return mockRequestAdapter;
	}
}

public class Program
{
	public static async Task Main()
	{
		var mockRequestAdapter = RequestAdapterMockFactory.Create();
		mockRequestAdapter.Setup(adapter => adapter.SendAsync(
			It.Is<RequestInformation>(info => info.HttpMethod == Method.GET && info.UrlTemplate.Contains("/groups/")),
			// 👇🏻 Must match the type that service client should return
			Group.CreateFromDiscriminatorValue,
			It.IsAny<Dictionary<string, ParsableFactory<IParsable>>>(), It.IsAny<CancellationToken>()))
			.ReturnsAsync((
				RequestInformation info,
				// Must match the 👇🏻 type that service client should return
				ParsableFactory<Group> _,
				Dictionary<string, ParsableFactory<IParsable>> _,
				// Must match the type that 👇🏻 service client should return
				CancellationToken _) => new Group
						  {
							  Id = (string)info.PathParameters["group%2Did"],
						  });
		
		var graphClient = new GraphServiceClient(mockRequestAdapter.Object);
		var group = await graphClient.Groups["fooBar"].GetAsync();
		
		Console.WriteLine(group.Id);
	}
}

olivermue avatar Feb 22 '24 07:02 olivermue