I've got data access layer that has two types of method GetLatestX
and GetX
. GetLatestX
looks something like this:
public IElementType GetLatestElementType(Guid id)
{
IElementType record = null;
using (DatabaseSession session = CreateSession())
{
record = session.Connection.Get<ElementTypeRecord>(id);
}
return record;
}
That's reasonably easy to unit test.
However, GetX
wraps this GetLatest
in a RefCount Observable and emits new values in response to a messaging system. Testing this method is a lot more complex. I want to check the following, rather complex behavior:
- You can subscribe and it retrieves a value from the database
- It starts listening for messages
- Subscribing again doesn't result in a repeated database call
- When the mock message system simulates a message a new database access is called and the subscriptions get the new versions. Only one additional database call is used.
- Unsubscribing the second subscription doesn't result in the system stopping listening to messages.
- Unsubscribing to the first subscription results in the disposal of resources, and un-subscription from the messages.
So, I've got all this in a single unit test, which is hideous. However, I'm not sure how I could break it up. I could only test 1, but to test 2 I'd have to go through 1 and 2, for 3 I'd still have to go through steps 1, 2, 3 etc. So I'd just be copying the same giant test, but having Asserts in different places each time.
And the code I'm testing in this method:
public IObservable<IElement> GetElement(Guid id)
{
return CreateObservableFor(GetLatestElement(id), GetLatestElement);
}
It's a single line, half of which has been tested earlier. The other half is private:
private IObservable<T> CreateObservableFor<T>(T entity, Func<Guid, T> getLatest)
{
Guid id = (entity as ConfigurationEntity).ID;
//return subject;
return Observable.Create<T>(observer =>
{
// publish first value
observer.OnNext(entity);
// listen to internal or external update notifications from messages
Action<ConfigurationMessage> callback = (message) =>
{
// TODO, check timestamp is after previous?
// use callback to get latest value
observer.OnNext(getLatest(id));
};
messageService.SubscribeToConfiguration(id.ToString(), callback);
// if it has been completed and stop listening to messages
return Disposable.Create(() =>
{
Console.WriteLine("Unsubscribing from topic " + id);
messageService.UnsubscribeToConfiguration(id.ToString(), callback);
});
}).Publish().RefCount();
}
But it behaves the same way for all the GetX
methods.
My first thought is I should split the GetLatestX
into an interface I can test separately then mock - but that seems to split the data access class into two for no good reason other than unit tests. They don't really conceptually belong as separate units in my mind. Is there another way of 'mocking' this dependency within a class? Or should I just split them up for the sake of testing?
In the same vein, testing the functionality of GetX is effectively repeatedly testing the logic of CreateObservableFor
. I see why I should be testing each API method rather than what is really the internals of the API (in case something changes), but it seems so... inefficient.
How can I structure this unit test in a better way?
Example test:
[Test]
public void GetElementTypeTest()
{
// test data
var id = Guid.NewGuid();
var nameA = "TestNameA";
var nameB = "TestNameB";
// mock database
var columnNames = new[] { "ID", "Name" };
// data values A will be the first set of data returned, and after configuration update B will be returned
var dataValuesA = new List<object[]>();
dataValuesA.Add(new object[] { id, nameA });
var dataValuesB = new List<object[]>();
dataValuesB.Add(new object[] { id, nameB });
mockDbProviderFactory = new MockDbProviderFactory()
.AddDatareaderCommand(columnNames, dataValuesA)
.AddDatareaderCommand(columnNames, dataValuesB);
// test method
IEMF emf = new EMF(mockMessageService.Object, new MockHistorian(), mockDbProviderFactory.Object, "");
var resultObservable = emf.GetElementType(id);
// check subscription to config changes has not occurred and database not accessed
mockDbProviderFactory.Verify(f => f.CreateConnection(), Times.Once);
mockMessageService.Verify(ms => ms.SubscribeToConfiguration(It.IsAny<string>(), It.IsAny<Action<ConfigurationMessage>>()), Times.Never);
//subscribe to observable
int sub1Count = 0;
var subscription = resultObservable.Subscribe(result => {
sub1Count++;
// check result
Assert.AreEqual(new ElementTypeRecord(id, (sub1Count == 1 ? nameA : nameB)), result, "Result from EMF does not match data");
});
// check subscribed to config changes and subscription called
Assert.IsTrue(sub1Count == 1, "Subscription not called");
mockMessageService.Verify(ms => ms.SubscribeToConfiguration(It.IsAny<string>(), It.IsAny<Action<ConfigurationMessage>>()), Times.Once);
// check we've subscribed with our id
Assert.AreEqual(this.configCallbacks[0].Item1, id.ToString(), "Unexpected message system subscription topic");
// open a second, short term subscription and ensure that the system does not re-subscribe to updates, or read the data again
int sub2Count = 0;
resultObservable.Take(1).Subscribe(result => {
sub2Count++;
// check result (should be second data item)
Assert.AreEqual(new ElementTypeRecord(id, nameB), result, "Result from EMF does not match data");
});
// check subscribed to config changes has not changed
mockMessageService.Verify(ms => ms.SubscribeToConfiguration(It.IsAny<string>(), It.IsAny<Action<ConfigurationMessage>>()), Times.Once);
//emit a new value by simulating a configuration change message
this.configCallbacks[0].Item2(new ConfigurationMessage(DateTime.Now));
// check subscriptions called
Assert.IsTrue(sub1Count == 2, "Subscription not called");
Assert.IsTrue(sub2Count == 1, "Subscription not called");
// unsubscribe
mockMessageService.Verify(ms => ms.UnsubscribeToConfiguration(It.IsAny<string>(), It.IsAny<Action<ConfigurationMessage>>()), Times.Never);
subscription.Dispose();
// verify subscription removed
mockMessageService.Verify(ms => ms.UnsubscribeToConfiguration(It.IsAny<string>(), It.IsAny<Action<ConfigurationMessage>>()), Times.Once);
Assert.IsTrue(this.configCallbacks.Count == 0, "Unexpected message system unsubscription topic");
// validate that the connection, command and reader were used correctly
mockDbProviderFactory.Verify(f => f.CreateConnection(), Times.Exactly(2));
mockDbProviderFactory.MockConnection.Verify(c => c.Open(), Times.Exactly(2));
mockDbProviderFactory.MockConnection.Verify(c => c.Close(), Times.Exactly(2));
//first data call
mockDbProviderFactory.MockCommands[0].Verify(c => c.PublicExecuteDbDataReader(It.IsAny<CommandBehavior>()), Times.Once);
mockDbProviderFactory.MockCommands[0].MockDatareader.Verify(dr => dr.Read(), Times.Exactly(2));
//second data call
mockDbProviderFactory.MockCommands[1].Verify(c => c.PublicExecuteDbDataReader(It.IsAny<CommandBehavior>()), Times.Once);
mockDbProviderFactory.MockCommands[1].MockDatareader.Verify(dr => dr.Read(), Times.Exactly(2));
}