microsoftgraph / msgraph-sdk-dotnet

Microsoft Graph Client Library for .NET!
https://graph.microsoft.com
Other
703 stars 249 forks source link

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

Open Steve887 opened 1 year ago

Steve887 commented 1 year ago

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?

andrueastman commented 1 year ago

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?

Steve887 commented 1 year ago

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?

gitdj commented 1 year ago

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

here's method image

MockTestCase image

konri1990 commented 1 year ago

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 :)

ysbakker commented 1 year ago

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?

olivermue commented 1 year ago

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.

LockTar commented 1 year ago

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 commented 1 year ago

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

obsad1an commented 1 year ago

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

timvandesteeg commented 1 year ago

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

jackwalkernz commented 9 months ago

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

yagni commented 9 months ago

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

jackwalkernz commented 9 months ago

Ah! Fantastic, thanks so much @yagni! That works perfectly πŸ™πŸΌ

jackwalkernz commented 9 months ago

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?

olivermue commented 9 months ago

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.

kfwalther commented 9 months ago
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);
yagni commented 9 months ago

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 commented 9 months ago
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.

olivermue commented 9 months ago
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);
    }
}
sherlock1982 commented 2 weeks ago

Here's another example using FakeItEasy to read and write subscriptions:

        var adapter = A.Fake<IRequestAdapter>();

        // Read existing subscriptions
        var readSubscriptions = A.CallTo(() => adapter.SendAsync(A<RequestInformation>.Ignored,
            A<ParsableFactory<SubscriptionCollectionResponse>>.Ignored,
            A<Dictionary<string, ParsableFactory<IParsable>>>.Ignored,
            A<CancellationToken>.Ignored)
        );
        readSubscriptions.ReturnsLazily((RequestInformation requesInfo,
            ParsableFactory<SubscriptionCollectionResponse> factory,
            Dictionary<string, ParsableFactory<IParsable>>? errorMapping,
            CancellationToken cancellationToken) =>
        {
            return Task.FromResult<SubscriptionCollectionResponse?>(new SubscriptionCollectionResponse()
            {
                Value = []
            });
        });

        // Post new subscription
        var createSub = A.CallTo(() => adapter.SendAsync(A<RequestInformation>.Ignored,
            A<ParsableFactory<Subscription>>.Ignored,
            A<Dictionary<string, ParsableFactory<IParsable>>>.Ignored,
            A<CancellationToken>.Ignored)
        );
        createSub.ReturnsLazily((RequestInformation requesInfo,
                ParsableFactory<Subscription> factory,
                Dictionary<string, ParsableFactory<IParsable>>? errorMapping,
                CancellationToken cancellationToken) =>
            {
                return Task.FromResult<Subscription?>(new Subscription());
            });

        var client = new GraphServiceClient(adapter);