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:
- When the lock is locked, the job is NOT executed.
- When the lock is NOT locked, the job is executed.
But I'm missing the following important case:
- Guarantee, that during the duration of the exceution, the lock is active.
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?
CodePudding user response:
By "execute once" do you mean once ever, and every invocation after that returns the same results? Or just that it can only execute one job at a time? Based off of the code, I'm assuming the latter, but wanted to check for clarity.
Here's some good information on locking from someone way smarter than me: https://stackoverflow.com/a/12316461/10243808
His explanation and some of the examples in the provided links would give you a lot of options for handling what to return and when, which potentially could be used to unit test that last scenario (I guess even without refactoring you could return some error message inside the if(@lock.isLocked()) statement.) Although at that point after refactoring, I'd argue you're testing C# functionality, which I'd assume is correct the vast majority of the time.
CodePudding user response:
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);
}