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

时间:2016-04-25 16:14:04

标签: c# unit-testing asynchronous mvvm

我正在开发一个将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属性setter中调用。我的想法是,我在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中的代码是我仍然担心的。我可以将它更改为受保护的等等并在测试中等待它,但我仍然留下了一个问题,我不知道如何测试它在执行验证之前将其设置为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语句来让编译器退出警告我,我没有等待异步方法(你不能在属性设置器中做)。我认为在重写之后现在更好地展示了我之前可能还不够清楚的问题。我知道我可以将其写为异步并等待它,我可以测试它是否被正确标记为无效或经过验证。我的问题是,如何在执行验证之前测试它是否首先将其标记为验证?它在执行验证之前执行此操作,但是一旦等待此函数的结果,它基本上太晚了,因为您获得的结果将无效或验证。我不确定如何测试它。在我看来,我认为可能有办法伪造新修订的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"
            };
        }
    }

2 个答案:

答案 0 :(得分:2)

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

很少违反SRP,

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

其次,看了这个方法后,看起来你需要更多地编写单元测试。

  

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

虽然单元测试会提升Refactoring,但首先你要重构你的方法,然后开始编写你可能自己解决的测试。

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

答案 1 :(得分:0)

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

如果您将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方法上,只需忽略回调方法。

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

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