Search code examples
c#unit-testingxunit.net

Unit test a simple function by observing the external behavior


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?


Solution

  • 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);
    }