简体   繁体   中英

Unit test (NUnit, Nsubstitute) ASP Core Service with MongoDB

I got a simple application that calls a MongoDB collection, and it does various things with it.

I want to unit test my service layer using NUnit, Nsubstitute, but I have no idea how to mock a data collection that my service layer consumes.

Here is my current setup:

AutoDB:

public class AutoDb : IAutoDb
{
    private readonly IMongoCollection<Auto> _AutosCollection;

    public AutoDb(IConfiguration config)
    {
        var client = new MongoClient(config.GetConnectionString("DatabaseConnection"));
        var database = client.GetDatabase("AutoDb");

        _AutosCollection = database.GetCollection<Auto>("Autos");

        var AutoKey = Builders<Auto>.IndexKeys;
        var indexModel = new CreateIndexModel<Auto>(AutoKey.Ascending(x => x.Email), new CreateIndexOptions {Unique = true});

        _AutosCollection.Indexes.CreateOne(indexModel);
    }

    public async Task<List<Auto>> GetAll()
    {
        return await _AutosCollection.Find(_ => true).ToListAsync();
    }

    public async Task<Auto> Get(Guid id)
    {
        return await _AutosCollection.Find<Auto>(o => o.Id == id).FirstOrDefaultAsync();
    }

    public async Task<Auto> Create(Auto Auto)
    {
        await _AutosCollection.InsertOneAsync(Auto);
        return Auto;
    }

    public async Task Update(Guid id, Auto model)
    {
        await _AutosCollection.ReplaceOneAsync(o => o.Id == id, model);
    }

    public async Task Remove(Auto model)
    {
        await _AutosCollection.DeleteOneAsync(o => o.Id == model.Id);
    }

    public async Task Remove(Guid id)
    {
        await _AutosCollection.DeleteOneAsync(o => o.Id == id);
    }

    public IMongoQueryable<Auto> GetQueryable() => _AutosCollection.AsQueryable();
}

public interface IAutoDb
{
    Task<List<Auto>> GetAll();

    Task<Auto> Get(Guid id);

    Task<Auto> Create(Auto Auto);

    Task Update(Guid id, Auto model);

    Task Remove(Auto model);

    Task Remove(Guid id);

    IMongoQueryable<Auto> GetQueryable();
}

My Service Layer

public class AutoService : IAutoService
{
    private readonly IAutoDb _AutoDb;

    public AutoService(IAutoDb AutoDb)
    {
        _AutoDb = AutoDb;
    }

    public async Task<Auto> CreateProfile(AutoModel model)
    {

        var Auto = new Auto
        {
            Id = new Guid(),
            Type = model.Type,
            Name = model.Name,
        };

        try
        {
            await _AutoDb.Create(Auto);

        }
        catch (MongoWriteException mwx)
        {
            Debug.WriteLine(mwx.Message);
            return null;
        }

        return Auto;
    }

    public async Task<Auto> GetAutoById(Guid id)
    {
        var retVal = await _AutoDb.Get(id);

        return retVal;
    }

    public Task<Auto> EditAuto(AutoModel model)
    {
        throw new NotImplementedException();
    }
}

public interface IAutoService
{
    Task<Auto> CreateProfile(AutoModel model);
    Task<Auto> EditAuto(AutoModel model);
    Task<Auto> GetAutoById(Guid id);

}

My attempt at unit testing the service layer:

public class AutoServiceTests
{
    private IAutoDb _AutoDb;

    [SetUp]
    public void Setup()
    {
        _AutoDb = Substitute.For<IAutoDb>();

        // I don't know how to mock a dataset that contains
        // three auto entities that can be used in all tests
    }

    [Test]
    public async Task CreateAuto()
    {
        var service = new AutoService(_AutoDb);

        var retVal = await service.CreateProfile(new AutoModel
        {
            Id = new Guid(),
            Type = "Porsche",
            Name = "911 Turbo",
        });

        Assert.IsTrue(retVal is Auto);
    }

    [Test]
    public async Task Get3Autos() {
        var service = new AutoService(_AutoDb);

        // Stopped as I don't have data in the mock db
    }

    [Test]
    public async Task Delete1AutoById() {
        var service = new AutoService(_AutoDb);

        // Stopped as I don't have data in the mock db
    }
}

How can I create a mockdb collection that can be consumed by all the tests in the class?

In my opinion, your IAutoDb looks like a leaky abstraction when it exposes IMongoQueryable<Auto> .

Aside from that, there really is no need for a backing store in order to test the service.

Take your first test CreateAuto . Its behavior can be asserted by configuring the mock accordingly:

public async Task CreateAuto() {

    // Arrange
    var db = Substitute.For<IAutoDb>();

    // Configure mock to return the passed argument
    db.Create(Arg.Any<Auto>()).Returns(_ => _.Arg<Auto>());

    var service = new AutoService(db);
    var model = new AutoModel {
        Id = new Guid(),
        Type = "Porsche",
        Name = "911 Turbo",
    };

    // Act
    var actual = await service.CreateProfile(model);

    // Assert
    Assert.IsTrue(actual is Auto);
}

For the other two tests, there were not any implementations in the subject service to reflect what needed to be tested, so I created some samples,

public interface IAutoService {

    // ...others omitted for brevity

    Task RemoveById(Guid id);
    Task<List<Auto>> GetAutos();
}

public class AutoService : IAutoService {
    private readonly IAutoDb _AutoDb;

    public AutoService(IAutoDb AutoDb) {
        _AutoDb = AutoDb;
    }

    // ...others omitted for brevity

    public Task RemoveById(Guid id) {
        return _AutoDb.Remove(id);
    }

    public Task<List<Auto>> GetAutos() {
        return _AutoDb.GetAll();
    }
}

in order to demonstrate a simple way to test them.

[Test]
public async Task Get3Autos() {
    var db = Substitute.For<IAutoDb>();
    var expected = new List<Auto>() {
        new Auto(),
        new Auto(),
        new Auto(),
    };
    db.GetAll().Returns(expected);

    var service = new AutoService(db);

    // Act
    var actual = await service.GetAutos();

    // Assert
    CollectionAssert.AreEqual(expected, actual);
}

[Test]
public async Task Delete1AutoById() {

    // Arrange
    var expectedId = Guid.Parse("FF28A47B-9A87-4184-919A-FDBD414D0AB5");
    Guid actualId = Guid.Empty;
    var db = Substitute.For<IAutoDb>();
    db.Remove(Arg.Any<Guid>()).Returns(_ => {
        actualId = _.Arg<Guid>();
        return Task.CompletedTask;
    });

    var service = new AutoService(db);

    // Act
    await service.RemoveById(expectedId);

    // Assert
    Assert.AreEqual(expectedId, actualId);
}

Ideally you want to verify the expected behavior of the subject under test. Therefore you mock the expected behavior so that the subject under test behaves as expected when the tests are exercised.

I think Nkosi has a correct answer for demonstrating the use of a mocking library. In the comment thread on the question I was asked for an example using a test implementation rather than a mocking library. So here it is, with the proviso from the comment thread that IMongoQueryable<Auto> GetQueryable() doesn't fit in a persistence-agnostic interface and so we can remove it or replace it with IQueryable or another adapter.

There are many ways to do this. I've used a backing list (could also use a dictionary/map keyed by id) to implement an in-memory version of IAutoDb : (Disclaimer: rough draft. Please review and test thoroughly before using this anywhere)

class TestAutoDb : IAutoDb
{
    public List<Auto> Autos = new List<Auto>();
    public Task<Auto> Create(Auto auto) {
        Autos.Add(auto);
        return Task.FromResult(auto);
    }

    public Task<Auto> Get(Guid id) => Task.Run(() => Autos.Find(x => x.Id == id));
    public Task<List<Auto>> GetAll() => Task.FromResult(Autos);
    public Task Remove(Auto model) => Task.Run(() => Autos.Remove(model));
    public Task Remove(Guid id) => Task.Run(() => Autos.RemoveAll(x => x.Id == id));
    public Task Update(Guid id, Auto model) => Remove(id).ContinueWith(_ => Create(model));
}

We can now test against known states of the in-memory database:

[Fact]
public async Task Get3Autos() {
    var db = new TestAutoDb();
    // Add 3 autos
    var firstGuid = new Guid(1, 2, 3, new byte[] { 4, 5, 6, 7, 8, 9, 10, 11 });
    db.Autos = new List<Auto> {
        new Auto { Id = firstGuid, Name = "Abc" },
        new Auto { Id = Guid.NewGuid(), Name = "Def" },
        new Auto { Id = Guid.NewGuid(), Name = "Ghi" }
    };
    var service = new AutoService(db);

    // Check service layer (note: just delegates to IAutoDb, so not a very useful test)
    var result = await service.GetAutoById(firstGuid);

    Assert.Equal(db.Autos[0], result);
}

I think manually implementing test classes like this is a good way to get started with testing, rather than skipping straight to a mocking library.

Mocking libraries automate the creation of these test classes, and makes it a bit easier to change behaviour for each test (eg making a call to Get return a failed task to simulate a network error or similar), but you can also do this manually. If you get bored doing this by hand, that is a good time to look to a mocking library to make this easier. :)

There are also advantages to avoid mocking libraries entirely. It is arguably simpler to have the explicitly implemented test class. The team doesn't need to learn a new library, it is convenient to reuse it across multiple tests and fixtures (possibly using it to also test more complex integration scenarios), and could even potentially be used in the app itself (for example: to provide a demo mode or similar).

Due to the nature of this particular interface (its members have implied contracts between them: calling create and then getting that id should return the newly created instance), I would lean towards using the explicit test class in this case so I can ensure those contracts are adhered to. To me mocking is most useful when I don't care about those contracts. I just need to know a certain member was called, or that when another member returns a specific result then my class acts in the expected way.

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