繁体   English   中英

如何对该异步代码进行单元测试?

[英]How do I unit test this asynchronous code?

我正在做一个将gui放入第三方数据库备份实用程序的项目。 这是我在编写单元测试时所做的第一个项目。 到目前为止,我一直在使用TDD方法编写几乎整个项目,但是我对此功能感到困惑,只是在没有TDD的情况下编写了该功能。 回去时,我仍然不知道如何测试它。

注意如果您是第一次阅读此帖子, 忽略下面的代码,并检出下面的“编辑#2”,这是该代码的重构版本。

    private void ValidateCustomDbPath()
    {
        if (_validateCustomDbPathTask != null && !_validateCustomDbPathTask.IsCompleted)
        {
            _validateCustomDbPathCancellationTokenSource.Cancel();
        }

        if (string.IsNullOrEmpty(_customDbPath))
        {
            Set(() => CustomDbPathValidation, ref _customDbPathValidation, ValidationState.Validated);
            Set(() => CustomDbPathValidationMessage, ref _customDbPathValidationMessage, "");
            _customDbPathCompany = string.Empty;
            UpdateDefaultBackupPath();
            return;
        }

        _validateCustomDbPathCancellationTokenSource = new CancellationTokenSource();
        var ct = _validateCustomDbPathCancellationTokenSource.Token;

        _validateCustomDbPathTask = Task.Run(async () =>
        {
            Set(() => CustomDbPathValidation, ref _customDbPathValidation, ValidationState.Validating);
            Set(() => CustomDbPathValidationMessage, ref _customDbPathValidationMessage, "");
            try
            {
                if (!_diskUtils.File.Exists(_customDbPath))
                {
                    Set(() => CustomDbPathValidation, ref _customDbPathValidation, ValidationState.Invalid);
                    Set(() => CustomDbPathValidationMessage, ref _customDbPathValidationMessage, "File not found");
                    _customDbPathCompany = string.Empty;
                    UpdateDefaultBackupPath();
                    return;
                }
                using (var conn = _connectionProvider.GetConnection(_customDbPath, false))
                using (var trxn = conn.BeginTransaction())
                {

                    var dbSetup = await _dbSetupRepo.GetAsync(conn, trxn);
                    _customDbPathCompany = dbSetup.Company;
                    Set(() => CustomDbPathValidation, ref _customDbPathValidation, ValidationState.Validated);
                    Set(() => CustomDbPathValidationMessage, ref _customDbPathValidationMessage, "");
                    UpdateDefaultBackupPath();
                }
            }
            catch
            {
                Set(() => CustomDbPathValidation, ref _customDbPathValidation, ValidationState.Invalid);
                Set(() => CustomDbPathValidationMessage, ref _customDbPathValidationMessage, "Error getting company");
                _customDbPathCompany = string.Empty;
                UpdateDefaultBackupPath();
            }
        }, ct);
    }

此方法在屏幕的ViewModel中。 设置新值后,将在CustomDbPath属性设置器中调用它。 我的想法是,我在gui中具有可视指示器,以显示提供的路径是否有效,并且UpdateDefaultBackupPath方法根据所选数据库中的信息更新建议的备份文件名。

解释您在这里看到的内容,如果第一个IF块已经运行但尚未完成,则它取消了验证任务(我知道我还没有使用取消令牌)。 在第二个块中,如果没有提供路径(起始状态),我不想显示错误,也不需要进一步验证。 在任务中,我首先指出该字段正在验证中,然后检查是否可以在磁盘上找到数据库文件,最后检查是否在数据库中寻找要用于命名备份文件名的信息。 我正在使用MVVM Light,这是Set方法的来源(它实现了INotifyPropertyChanged)。

到目前为止,在我使用过任务的其他所有地方,我都没有进行过问题测试。 我等待有问题的方法并测试结果。 这种情况是不同的。 这在属性设置器中被称为,它显然不能遵循异步等待模式,而且我也不想这样做,因为用户可以在第一个验证序列完成之前再次更改属性。 我对测试感兴趣的主要内容是CustomDbPathValidation和CustomDbPathValidationMessage值。 它是否设置为在验证之前进行验证。 成功时将其设置为已验证,失败时是否设置为无效。 我很乐意以一种使其可测试的方式重写此方法,我只是不知道该怎么做。 有任何想法吗?

编辑#2:

本着@vendettamit建议遵循SRP的精神,我对功能进行了细分。 GetCompanyInfoFromDbAsync用于在适当时从数据库中获取公司信息。 GetCompanyInfoAsync确定是否在可能的情况下从数据库中检索信息(如果找不到数据库)。 最终,这两种方法将移至另一个类,并公开进行测试。 我将它们移到的类将通过构造函数注入到此处显示的类中。

关于@vendettamit提出的一些观点:

“您正在设置路径是否为空(不应作为验证方法的一部分)”

我认为您误读了代码。 如果路径为空白,我将公司设置为空白。 这段代码的重点是获取公司名称并使用它来编写文件名。

我不确定GetCompanyInfoAsync是否满足您的SRP标准,但是尝试将其分解得比本次编辑中的要多,这对我来说似乎很奇怪。

'在所有路径“代码气味”中调用UpdateDefaultBackupPath()”

我猜您是在写完第一篇文章之前写的。 回顾一下代码,我得出了相同的结论,并且已经对其进行了重构,因此被称为一次。

“最后我看到了您的编辑,即ref _customDbPathValidationMessage上帝与您同在。”

虽然我同意通常很少使用ref,但我觉得这里是适当的。 Set方法来自该类派生的MVVM Light ViewModelBase基类。 它有助于使用INotifyPropertyChanged“模式”。 它们具有不会更新后备字段的功能,但是当我需要更改后备字段并通知我时,我选择使用Set方法来减少所需的代码。 第一个参数是一个Expression,它使您可以指定属性,针对该属性,将以编译器可以帮助您捕获错别字的方式引发通知。 ref参数是您提供属性的后备字段的位置,下一个参数是要分配给后备字段的新值。 我可能没有使用Set,而是使用了ViewModelBase中提供的其他帮助程序方法来引发通知,然后在此之前手动设置后备字段。 但为什么? 为什么要添加更多代码? 我不知道它将实现什么。

根据先前调用的状态(Task和TaskCancellationSource)对函数的注释,我看不到任何解决方法。 我需要能够在不让安装员等待任务完成的情况下启动此功能。 在他们输入绑定到CustomDbPath字段的编辑框的字母后,我无法“暂停”它。 当按下“备份”按钮(VM上的命令)时,我将需要检查任务是否正在运行,并等待任务完成。

我仍然担心ValidateCustomDbPathAsync中的代码。 我可以将其更改为protected等,然后在测试中等待它,但是我仍然遇到的问题是,在执行验证之前,我不知道如何测试将其设置为Validating,因为到了等待返回时结果来不及检查。 这最终是我遇到的问题,即使在重构后,我也看不到实现这一目标的简便方法。

注意 -这越来越长。 StackOverflow是否愿意保留以前的编辑,还是应该删除第一个编辑以缩短此问题的时间?

    public string CustomDbPath
    {
        get { return _customDbPath; }
        set
        {
            if (_customDbPath != value)
            {
                Set(() => CustomDbPath, ref _customDbPath, value);
                ValidateCustomDbPath();
            }
        }
    }

    private void ValidateCustomDbPath()
    {
        if (_validateCustomDbPathCts != null)
        {
            _validateCustomDbPathCts.Cancel();
            _validateCustomDbPathCts.Dispose();
        }

        _validateCustomDbPathCts = new CancellationTokenSource();
        _validateCustomDbPathTask = ValidateCustomDbPathAndUpdateDefaultBackupPathAsync(_validateCustomDbPathCts.Token);
    }

    private async Task ValidateCustomDbPathAndUpdateDefaultBackupPathAsync(CancellationToken ct)
    {
        var companyInfo = await ValidateCustomDbPathAsync(ct);

        _customDbPathCompany = companyInfo.Company;
        UpdateDefaultBackupPath();
    }

    private async Task<CompanyInfo> ValidateCustomDbPathAsync(CancellationToken ct)
    {
        Set(() => CustomDbPathValidation, ref _customDbPathValidation, ValidationState.Validating);
        Set(() => CustomDbPathValidationMessage, ref _customDbPathValidationMessage, string.Empty);

        var companyInfo = await GetCompanyInfoAsync(_customDbPath, ct);

        Set(() => CustomDbPathValidation, ref _customDbPathValidation, companyInfo.Error ? ValidationState.Invalid : ValidationState.Validated);
        Set(() => CustomDbPathValidationMessage, ref _customDbPathValidationMessage, companyInfo.ErrorMsg);
        return companyInfo;
    }

    private async Task<CompanyInfo> GetCompanyInfoAsync(string dbPath, CancellationToken ct)
    {
        if (string.IsNullOrEmpty(dbPath))
        {
            return new CompanyInfo
            {
                Company = string.Empty,
                Error = false,
                ErrorMsg = string.Empty
            };
        }

        if (_diskUtils.File.Exists(dbPath))
        {
            return await GetCompanyInfoFromDbAsync(dbPath, ct);
        }

        ct.ThrowIfCancellationRequested();

        return new CompanyInfo
        {
            Company = string.Empty,
            Error = true,
            ErrorMsg = "File not found"
        };
    }

    private async Task<CompanyInfo> GetCompanyInfoFromDbAsync(string dbPath, CancellationToken ct)
    {
        try
        {
            using (var conn = _connectionProvider.GetConnection(dbPath, false))
            using (var trxn = conn.BeginTransaction())
            {
                ct.ThrowIfCancellationRequested();

                var dbSetup = await _dbSetupRepo.GetAsync(conn, trxn);

                ct.ThrowIfCancellationRequested();

                return new CompanyInfo
                {
                    Company = dbSetup.Company,
                    Error = false,
                    ErrorMsg = string.Empty
                };
            }
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch
        {
            return new CompanyInfo
            {
                Company = string.Empty,
                Error = true,
                ErrorMsg = "Error getting company"
            };
        }
    }

编辑#1:

从@BerinLoritsch提出的一些建议中,我将方法重写为异步方法,并将某些逻辑分解为另一个方法,该方法以后可放入另一个类中并在测试期间进行伪造。 我必须添加一条pragma语句以使编译器退出,并警告我我没有在等待async方法(在属性设置器中无法做到)。 我认为重写后现在可以更好地显示我遇到的问题,而我之前可能还不太清楚。 我知道我可以将其编写为异步并等待它,并且可以测试它是否已正确标记为无效或已验证。 我的问题是,如何在执行验证之前测试它是否首先将其标记为验证? 它在执行验证之前就执行了此操作,但是一旦您等待该函数的结果,则基本上为时已晚,因为您获得的结果将无效或经过验证。 我不确定该如何测试。 在我看来,我想可能有一种方法可以伪造新修订的GetCompanyInfoAsync方法,以返回在测试中“停顿”的任务,直到我希望它完成。 如果我可以控制它的完成时间,那么也许我可以在完成之前测试ViewModel状态。

public string CustomDbPath
    {
        get { return _customDbPath; }
        set
        {
            if (_customDbPath != value)
            {
                Set(() => CustomDbPath, ref _customDbPath, value);
                #pragma warning disable 4014
                ValidateCustomDbPathAsync();
                #pragma warning restore 4014
            }
        }
    }

    private async Task ValidateCustomDbPathAsync()
    {
        if (_validateCustomDbPathTask != null && !_validateCustomDbPathTask.IsCompleted)
        {
            _validateCustomDbPathCancellationTokenSource.Cancel();
        }

        _validateCustomDbPathCancellationTokenSource = new CancellationTokenSource();
        var ct = _validateCustomDbPathCancellationTokenSource.Token;

        _validateCustomDbPathTask = Task.Run(async () =>
        {
            Set(() => CustomDbPathValidation, ref _customDbPathValidation, ValidationState.Validating);
            Set(() => CustomDbPathValidationMessage, ref _customDbPathValidationMessage, string.Empty);

            var companyInfo = await GetCompanyInfoAsync(_customDbPath, ct);

            Set(() => CustomDbPathValidation, ref _customDbPathValidation, companyInfo.Error ? ValidationState.Invalid : ValidationState.Validated);
            Set(() => CustomDbPathValidationMessage, ref _customDbPathValidationMessage, companyInfo.ErrorMsg);

            _customDbPathCompany = companyInfo.Company;
            UpdateDefaultBackupPath();
        }, ct);

        await _validateCustomDbPathTask;
    }

    private async Task<CompanyInfo> GetCompanyInfoAsync(string dbPath, CancellationToken ct)
    {
        if (string.IsNullOrEmpty(dbPath))
        {
            return new CompanyInfo
            {
                Company = string.Empty,
                Error = false,
                ErrorMsg = string.Empty
            };
        }

        if (!_diskUtils.File.Exists(dbPath))
        {
            return new CompanyInfo
            {
                Company = string.Empty,
                Error = true,
                ErrorMsg = "File not found"
            };
        }

        try
        {
            using (var conn = _connectionProvider.GetConnection(dbPath, false))
            using (var trxn = conn.BeginTransaction())
            {
                var dbSetup = await _dbSetupRepo.GetAsync(conn, trxn);
                return new CompanyInfo
                {
                    Company = dbSetup.Company,
                    Error = false,
                    ErrorMsg = string.Empty
                };
            }
        }
        catch
        {
            return new CompanyInfo
            {
                Company = string.Empty,
                Error = true,
                ErrorMsg = "Error getting company"
            };
        }
    }

首先,您应该真正重构此方法。 我看到一个单元中发生了很多事情,这就是为什么您面对为此编写测试的问题。 让我们做一点RCA(根本原因分析)

很少违反SRP,

  • _validateCustomDbPathTask是类变量,因此此方法的输出取决于对象的状态。
  • 您正在检查Task是否已在运行(验证状态)
  • 您要设置的路径是否为空(不应是验证方法的一部分)
  • 在“任务”中,您再次验证路径是否在磁盘上
  • 在所有路径“代码气味”中调用UpdateDefaultBackupPath()
  • ..最后我看到您的编辑,即ref _customDbPathValidationMessage上帝与您同在。

其次,看完此方法后,您似乎需要更多地编写单元测试。

在编写单元测试之前,请编写良好的可测试代码。

尽管单元测试促进了Refactoring ,所以首先应该重构方法,然后开始编写可以自己解决的测试。

提示 -重构它(删除异步代码),编写测试,因为它是同步方法。 然后将其更改为异步并修复您的测试。

通过最初的重构,您已经迈出了一步,可以对这些功能进行单元测试。 现在,您必须在可访问性与可测试性之间取得平衡。 简而言之,您可能不希望您想要的任何代码调用这些方法,但是您确实希望由单元测试调用这些方法。

如果将private更改为protected则可以选择扩展类并公开受保护的方法。 如果将private更改为internal ,则只要单元测试位于相同的名称空间(如果程序集是密封的,则在程序集)中,它们就可以访问代码。 或者,您可以将它们public并且所有人都可以访问它。

单元测试将如下所示:

 [Test]
 public async Task TestMyDbCode()
 {
     string dbPath = "path/to/db";
     // do your set up

     CompanyInfo info = await myObject.GetCompanyInfoAsync(dbPath, CancellationToken.None);

     Assert.That(info, Is.NotNull());
     // and finish your assertions.
 }

这样做的目的是分解测试,以便最小的单元是稳定的,并且依赖于它的代码可以同样地可预测。

您已经非常简单地设置了属性设置程序,以至于只要验证代码已经过良好的测试,它就真的不值得测试。


您有几个选择,但是在这种情况下,最不麻烦的方法是将ValidateCustomDbPath()声明为async返回任务。 它看起来像这样:

private async Task ValidateCustomDbPath()
{
    // prep work

    _validateCustomDbPathTask = Task.Run(async () =>
    {
        // all your DB stuff
    });

    await _validateCustomDbPathTask;
}

通过使async代码一直异步到可以调用它的地方,那么您已经有了一种机制来await它在单元测试中完成。 还有其他一些选项,如果ValidateCustomDbPath必须是Action,或者本身是从事件中调用的,则这些选项很有用。

该方法要求您有两种方法:

  • 返回等待的返回类型的async方法(通常是Task)
  • 简单地调用async方法的wrapper方法

好处是您可以将单元测试时间花费在实际的async方法上,而无需理会回调方法。

async void方法的问题在于,虽然您可以await其他异步方法,但不能等待返回void ,这就是为什么要测试代码时需要2个方法的原因。

最后一个选择是将数据库内容放入其自己的async Task方法中,您可以直接从单元测试中调用该方法。 您可以测试设置方法的路径,这些方法实际上并没有将数据库工作与数据库工作本身分开。 重要的是,您公开了一个async Task方法,您的单元测试可以利用该方法,并实际上等待工作完成。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM