Integration test causes Entity Framework to time out
Asked Answered
K

1

6

I'm currently writing integration tests using nunit for a previously untested server that was written in C# using ApiController and Entity Framework. Most of the tests run just fine, but I've ran into two that always cause the database to time out. The error messages look something like this:

System.Data.Entity.Infrastructure.DbUpdateException : An error occurred while updating the entries. See the inner exception for details.
System.Data.Entity.Core.UpdateException : An error occurred while updating the entries. See the inner exception for details.
System.Data.SqlClient.SqlException : Timeout expired. The timeout period elapsed prior to completion of the operation or the server is not responding.
System.ComponentModel.Win32Exception : The wait operation timed out

The first test that's timing out:

    [TestCase, WithinTransaction]
    public async Task Patch_EditJob_Success()
    {
        var testJob = Data.SealingJob;

        var requestData = new Job()
        {
            ID = testJob.ID,
            Name = "UPDATED"
        };

        var apiResponse = await _controller.EditJob(testJob.ID, requestData);
        Assert.IsInstanceOf<StatusCodeResult>(apiResponse);

        Assert.AreEqual("UPDATED", testJob.Name);
    }

The other test that's timing out:

    [TestCase, WithinTransaction]
    public async Task Post_RejectJob_Success()
    {
        var rejectedJob = Data.SealingJob;

        var apiResponse = await _controller.RejectJob(rejectedJob.ID);
        Assert.IsInstanceOf<OkResult>(apiResponse);

        Assert.IsNull(rejectedJob.Organizations);
        Assert.AreEqual(rejectedJob.JobStatus, JobStatus.OnHold);

        _fakeEmailSender.Verify(
            emailSender => emailSender.SendEmail(rejectedJob.Creator.Email, It.Is<string>(emailBody => emailBody.Contains(rejectedJob.Name)), It.IsAny<string>()),
            Times.Once());
    }

These are the controller methods that these tests are using: The timeout always happens on the first call to await db.SaveChangesAsync() within the controller. Other controller methods that are being tested also call SaveChangesAsync without any problem. I've also tried calling SaveChangesAsync from within the failing tests and it works fine there. Both of these methods they are calling work normally when called from within the controller, but time out when called from the tests.

    [HttpPatch]
    [Route("editjob/{id}")]
    public async Task<IHttpActionResult> EditJob(int id, Job job)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        if (id != job.ID)
        {
            return BadRequest();
        }

        Job existingJob = await db.Jobs
            .Include(databaseJob => databaseJob.Regions)
            .FirstOrDefaultAsync(databaseJob => databaseJob.ID == id);

        existingJob.Name = job.Name;

        // For each Region find if it already exists in the database
        // If it does, use that Region, if not one will be created
        for (var i = 0; i < job.Regions.Count; i++)
        {
            var regionId = job.Regions[i].ID;
            var foundRegion = db.Regions.FirstOrDefault(databaseRegion => databaseRegion.ID == regionId);
            if (foundRegion != null)
            {
                existingJob.Regions[i] = foundRegion;
                db.Entry(existingJob.Regions[i]).State = EntityState.Unchanged;
            }
        }

        existingJob.JobType = job.JobType;
        existingJob.DesignCode = job.DesignCode;
        existingJob.DesignProgram = job.DesignProgram;
        existingJob.JobStatus = job.JobStatus;
        existingJob.JobPriority = job.JobPriority;
        existingJob.LotNumber = job.LotNumber;
        existingJob.Address = job.Address;
        existingJob.City = job.City;
        existingJob.Subdivision = job.Subdivision;
        existingJob.Model = job.Model;
        existingJob.BuildingDesignerName = job.BuildingDesignerName;
        existingJob.BuildingDesignerAddress = job.BuildingDesignerAddress;
        existingJob.BuildingDesignerCity = job.BuildingDesignerCity;
        existingJob.BuildingDesignerState = job.BuildingDesignerState;
        existingJob.BuildingDesignerLicenseNumber = job.BuildingDesignerLicenseNumber;
        existingJob.WindCode = job.WindCode;
        existingJob.WindSpeed = job.WindSpeed;
        existingJob.WindExposureCategory = job.WindExposureCategory;
        existingJob.MeanRoofHeight = job.MeanRoofHeight;
        existingJob.RoofLoad = job.RoofLoad;
        existingJob.FloorLoad = job.FloorLoad;
        existingJob.CustomerName = job.CustomerName;

        try
        {
            await db.SaveChangesAsync();
        }
        catch (DbUpdateConcurrencyException)
        {
            if (!JobExists(id))
            {
                return NotFound();
            }
            else
            {
                throw;
            }
        }

        return StatusCode(HttpStatusCode.NoContent);
    }

    [HttpPost]
    [Route("{id}/reject")]
    public async Task<IHttpActionResult> RejectJob(int id)
    {
        var organizations = await db.Organizations
            .Include(databaseOrganization => databaseOrganization.Jobs)
            .ToListAsync();

        // Remove job from being shared with organizations
        foreach (var organization in organizations)
        {
            foreach (var organizationJob in organization.Jobs)
            {
                if (organizationJob.ID == id)
                {
                    organization.Jobs.Remove(organizationJob);
                }
            }
        }

        var existingJob = await db.Jobs.FindAsync(id);
        existingJob.JobStatus = JobStatus.OnHold;

        await db.SaveChangesAsync();

        await ResetJob(id);

        var jobPdfs = await DatabaseUtility.GetPdfsForJobAsync(id, db);

        var notes = "";
        foreach (var jobPdf in jobPdfs)
        {
            if (jobPdf.Notes != null)
            {
                notes += jobPdf.Name + ": " + jobPdf.Notes + "\n";
            }
        }

        // Rejection email
        var job = await db.Jobs
            .Include(databaseJob => databaseJob.Creator)
            .SingleAsync(databaseJob => databaseJob.ID == id);
        _emailSender.SendEmail(
            job.Creator.Email,
            job.Name + " Rejected",
            notes);

        return Ok();
    }

Other code that might be relevant:

The model being used is just a normal code-first Entity Framework class:

public class Job
{
    public Job()
    {
        this.Regions = new List<Region>();
        this.ComponentDesigns = new List<ComponentDesign>();
        this.MetaPdfs = new List<Pdf>();
        this.OpenedBy = new List<User>();
    }

    public int ID { get; set; }
    public string Name { get; set; }
    public List<Region> Regions { get; set; }

    // etc...
}

To keep the database clean between tests, I'm using this custom attribute to wrap each one in a transaction (from http://tech.trailmax.info/2014/03/how-we-do-database-integration-tests-with-entity-framework-migrations/):

public class WithinTransactionAttribute : Attribute, ITestAction
{
    private TransactionScope _transaction;

    public ActionTargets Targets => ActionTargets.Test;

    public void BeforeTest(ITest test)
    {
        _transaction = new TransactionScope();
    }

    public void AfterTest(ITest test)
    {
        _transaction.Dispose();
    }
}

The database connection and controller being tested is build in setup methods before each test:

[TestFixture]
public class JobsControllerTest : IntegrationTest
{
    // ...

    private JobsController _controller;
    private Mock<EmailSender> _fakeEmailSender;

    [SetUp]
    public void SetupController()
    {
        this._fakeEmailSender = new Mock<EmailSender>();
        this._controller = new JobsController(Database, _fakeEmailSender.Object);
    }

    // ...
}

public class IntegrationTest
{
    protected SealingServerContext Database { get; set; }
    protected TestData Data { get; set; }

    [SetUp]
    public void SetupDatabase()
    {
        this.Database = new SealingServerContext();
        this.Data = new TestData(Database);
    }

    // ...
}
Kaneshakang answered 25/5, 2016 at 21:54 Comment(5)
The statement that causes the timeout is the first await db.SaveChangesAsync() that occurs.Kaneshakang
Do the timeouts also occur if you run the tests in isolation? Async calls in integration test in transaction scopes can cause deadlocks. But then it's weird that always the same tests fail. Check the SQL statements that SaveChangesAsync executes.Barcarole
This might help https://mcmap.net/q/172699/-get-transactionscope-to-work-with-async-awaitOesophagus
Adding TransactionScopeAsyncFlowOption.Enabled did the trick! Thank you so much @jbl!Kaneshakang
Great ! You should post an answer to your own question and accept it, so that this question does not appear unanswered at first glanceOesophagus
K
5

This bug was apparently caused by the use of await within a TransactionScope. Following the top answer to this question, I added the TransactionScopeAsyncFlowOption.Enabled parameter when constructing the TransactionScope and the timeout issue went away.

Kaneshakang answered 27/5, 2016 at 14:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.