集成测试导致 Entity Framework 超时

Integration test causes Entity Framework to time out

我目前正在使用 nunit 为以前未测试的服务器编写集成测试,该服务器是使用 ApiController 和 Entity Framework 用 C# 编写的。大多数测试 运行 都很好,但我将 运行 分为两个,这总是导致数据库超时。错误消息看起来像这样:

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

第一个超时的测试:

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

另一个超时的测试:

    [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());
    }

这些是这些测试使用的控制器方法: 超时总是发生在控制器内第一次调用 await db.SaveChangesAsync() 时。正在测试的其他控制器方法也调用SaveChangesAsync没有任何问题。我也试过从失败的测试中调用 SaveChangesAsync 并且在那里工作正常。他们调用的这两个方法在从控制器中调用时正常工作,但在从测试中调用时超时。

    [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();
    }

其他可能相关的代码:

正在使用的模型只是一个普通的代码优先 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...
}

为了在测试之间保持数据库清洁,我使用此自定义属性将每个属性包装在一个 t运行saction 中(来自 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();
    }
}

正在测试的数据库连接和控制器是在每次测试之前在设置方法中构建的:

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

    // ...
}

这个错误显然是由于在 TransactionScope 中使用 await 引起的。按照 this question 的最佳答案,我在构造 TransactionScope 时添加了 TransactionScopeAsyncFlowOption.Enabled 参数,超时问题消失了。