I'm writing a Job class, and to ensure that this job can only be executed once, I have introduced a custom "Locking" mechanism.
The function looks like this:
public async Task StartAsync(CancellationToken cancellationToken)
{
if ([email protected]())
{
return;
}
[email protected]();
await this.ExecuteAsync(new JobExecutionContext(cancellationToken))
.ConfigureAwait(false);
[email protected]();
}
Now, when I write tests, I should test the external observable behavior, rather than testing implementation details, so I have the following tests at the moment:
[Theory(DisplayName = "Starting a `Job` (when the lock is locked), does NOT execute it.")]
[AutoDomainData]
public async Task StartingWithLockedLockDoesLockNotExecuteIt([Frozen] Mock<ILock> lockMock,
[Frozen] Mock<Job> jobMock)
{
// VALIDATION.
ILock @lock = lockMock?.Object ?? throw new ArgumentNullException(nameof(lockMock));
Job job = jobMock?.Object ?? throw new ArgumentNullException(nameof(jobMock));
// MOCK SETUP.
_ = lockMock.Setup(x => x.IsLocked())
.Returns(true);
// ACT.
await job.StartAsync(new CancellationToken())
.ConfigureAwait(false);
// ASSERT.
jobMock.Verify(job => job.ExecuteAsync(It.IsAny<IExecutionContext>()), Times.Never);
}
[Theory(DisplayName = "Starting a `Job` (when the lock is NOT locked), does lock the lock.")]
[AutoDomainData]
public async Task StartingWithNotLockedLockDoesExecuteIt([Frozen] Mock<ILock> lockMock,
[Frozen] Mock<Job> jobMock)
{
// VALIDATION.
ILock @lock = lockMock?.Object ?? throw new ArgumentNullException(nameof(lockMock));
Job job = jobMock?.Object ?? throw new ArgumentNullException(nameof(jobMock));
// MOCK SETUP.
_ = lockMock.Setup(x => x.IsLocked())
.Returns(false);
// ACT.
await job.StartAsync(new CancellationToken())
.ConfigureAwait(false);
// ASSERT.
jobMock.Verify(job => job.ExecuteAsync(It.IsAny<IExecutionContext>()), Times.Once);
}
Note: I'm using AutoFixture
, but I left the boilerplate code out.
Now, I have the following cases covered:
But I'm missing the following important case:
How can I properly test this? I have the feeling that the design should be updated, but I don't exactly know how.
Any advice?
Here's a Solution which I came up with.
I created my own "Job" and "Ilock" instead of mocking them.
internal sealed class TestableJob : Job
{
private readonly ILock @lock;
public TestableJob(ILock @lock)
: base(@lock)
{
this.@lock = @lock;
}
public bool IsLockedBeforeJobExecution
{
get; set;
}
public override Task ExecuteAsync(IExecutionContext executionContext)
{
this.IsLockedBeforeJobExecution = [email protected]();
return Task.CompletedTask;
}
}
internal sealed class TestableLock : ILock
{
private bool isLockedFlag;
public bool IsLocked()
{
return this.isLockedFlag;
}
public void Lock()
{
this.isLockedFlag = true;
}
public void Unlock()
{
this.isLockedFlag = false;
}
}
For some tests, I use these custom implementation, and on other ones, I use mocks.
[Theory(DisplayName = "The `Job` is NOT executed when the `ILock` is \"Locked\".")]
[AutoDomainData]
internal async Task TheJobIsNotExecutedWhenTheLockIsLocked([Frozen] Mock<ILock> lockMock,
[Frozen] Mock<Job> jobMock)
{
// VALIDATION.
ILock @lock = lockMock?.Object ?? throw new ArgumentNullException(nameof(lockMock));
Job job = jobMock?.Object ?? throw new ArgumentNullException(nameof(jobMock));
// MOCK SETUP.
_ = lockMock.Setup(x => x.IsLocked())
.Returns(true);
// ACT.
await job.StartAsync(new CancellationToken())
.ConfigureAwait(false);
// ASSERT.
jobMock.Verify(job => job.ExecuteAsync(It.IsAny<IExecutionContext>()), Times.Never);
}
[Theory(DisplayName = "The `Job` is executed when the `ILock` is \"NOT Locked\".")]
[AutoDomainData]
internal async Task TheJobIsExecutedWhenTheLockIsNotLocked([Frozen] Mock<ILock> lockMock,
[Frozen] Mock<Job> jobMock)
{
// VALIDATION.
ILock @lock = lockMock?.Object ?? throw new ArgumentNullException(nameof(lockMock));
Job job = jobMock?.Object ?? throw new ArgumentNullException(nameof(jobMock));
// MOCK SETUP.
_ = lockMock.Setup(x => x.IsLocked())
.Returns(false);
// ACT.
await job.StartAsync(new CancellationToken())
.ConfigureAwait(false);
// ASSERT.
jobMock.Verify(job => job.ExecuteAsync(It.IsAny<IExecutionContext>()), Times.Once);
}
[Fact(DisplayName = "The `ILock` is \"Locked\" before the `Job` is executed.")]
internal async Task TheLockIsLockedBeforeTheJobIsExecuted()
{
// ARRANGE.
var @lock = new TestableLock();
var job = new TestableJob(@lock);
// ACT.
await job.StartAsync(new CancellationToken())
.ConfigureAwait(false);
// ASSERT.
_ = job.IsLockedBeforeJobExecution
.Should()
.BeTrue();
}
[Fact(DisplayName = "The `ILock` is \"Unlocked\" once the `Job` is executed.")]
internal async Task TheLockIsUnlockedOnceTheJobIsExecuted()
{
// ARRANGE.
var @lock = new TestableLock();
var job = new TestableJob(@lock);
// ACT.
await job.StartAsync(new CancellationToken())
.ConfigureAwait(false);
// ASSERT.
_ = @lock.IsLocked()
.Should()
.BeFalse();
}
[Theory(DisplayName = "The `ILock` is \"Unlocked\" when the `Job` is stopped.")]
[AutoDomainData]
internal async Task TheLockIsUnlockedWhenTheJobIsStopped([Frozen] Mock<ILock> lockMock, [Frozen] Mock<Job> jobMock)
{
// VALIDATION.
ILock @lock = lockMock?.Object ?? throw new ArgumentNullException(nameof(lockMock));
Job job = jobMock?.Object ?? throw new ArgumentNullException(nameof(jobMock));
// ACT.
await job.StopAsync(new CancellationToken())
.ConfigureAwait(false);
// ASSERT.
lockMock.Verify(@lock => @lock.Unlock(), Times.Once);
}
[Theory(DisplayName = "The `ILock` is \"Unlocked\" when an exception is raised during the execution of the `Job`.")]
[AutoDomainData]
internal async Task TheLockIsUnlockedWhenAnExceptionIsRaisedDuringTheExecutionOfTheJob(
[Frozen] Mock<ILock> lockMock,
[Frozen] Mock<Job> jobMock)
{
// VALIDATION.
ILock @lock = lockMock?.Object ?? throw new ArgumentNullException(nameof(lockMock));
Job job = jobMock?.Object ?? throw new ArgumentNullException(nameof(jobMock));
// MOCK SETUP.
_ = jobMock.Setup(@mock => mock.ExecuteAsync(It.IsAny<IExecutionContext>()))
.Throws<ArgumentOutOfRangeException>();
// ACT.
await job.StartAsync(new CancellationToken())
.ConfigureAwait(false);
// ASSERT.
lockMock.Verify(@lock => @lock.Unlock(), Times.Once);
}