2016-11-01 2 views
1

Рассмотрим следующую весьма упрощенную модель представления для выборки и показывает список проектов:Можно ли исключить ошибки тестирования модулей в командах, обертывающих асинхронную лямбда?

public class ProjectListViewModel 
{ 
    private readonly IWebService _webService; 
    public ICommand RefreshCommand { get; } 
    // INotifyPropertyChanged implementation skipped for brevity 
    public ObservableCollection<Project> Projects { get; set; } 

    public ProjectListViewModel(IWebService serverApi) 
    { 
     _serverApi = serverApi; 
     // ICommand implemented by Xamarin.Forms 
     RefreshCommand = new Command(async() => await RefreshAsync()); 
    } 

    private async Task RefreshAsync() 
    { 
     try 
     { 
      Projects = await _webService.GetProjectsAsync(); 
     } 
     catch (TaskCanceledException) 
     { 
      // Empty (task only cancelled when we are navigating away from page) 
     } 
    } 
} 

Используя NUnit и Moq, я пытаюсь тест, когда GetProjectsAsync бросает TaskCanceledException, то ViewModel будет поймать его. Ближайший я получаю это:

[Test] 
public void When_Refreshing_Catches_TaskCanceledException() 
{ 
    // Arrange 
    webService = new Mock<IServerApi>(); 
    webService.Setup(mock => mock.GetProjectsAsync()) 
     .ThrowsAsync(new TaskCanceledException()); 
    vm = new ProjectListViewModel(webService.Object); 

    // Act and assert 
    Assert.That(() => vm.RefreshCommand.Execute(null), Throws.Nothing); 
} 

тест проходит, но, к сожалению, это ошибочный - это все еще проходит, если я брошу, например, Исключение вместо TaskCanceledException. Насколько я знаю, причина в том, что исключение не пробивается мимо команды lambda, async() => await RefreshAsync(), поэтому исключение, генерируемое GetProjectsAsync, никогда не будет обнаружено в результате теста. (При выполнении фактического приложение однако, TaskCanceledException будет пузыриться и разбить приложение, если не поймали. Я подозреваю, что это связано с контексты синхронизации, из которых у меня есть очень ограниченное понимание.)

Это работает, если я отладки тест - если я высмеиваю это, чтобы бросить Exception, он сломается на линии с помощью определения команды/лямбда, и если я выкину это TaskCanceledException, тест пройдет.

Обратите внимание, что результаты те же, если я использую Throws вместо ThrowsAsync. И в случае, если это имеет значение, я использую тестовый бегун в ReSharper 2016.2.

Использование nUnit, возможно ли вообще исключить исключения для тестирования при выполнении команд «async»? Возможно ли это без написания пользовательской реализации команды?

+1

Вы посмотрели на это? http://stackoverflow.com/a/15636814/4625433 –

ответ

6

Ваша проблема здесь:

new Command(async() => await RefreshAsync()) 

Это async лямбда преобразуется в async void метод компилятором.

В моей статье о async best practices я объясню, почему исключение нельзя поймать так. async методы не могут распространять свои исключения напрямую (поскольку их стек может исчезнуть к тому моменту исключения). async Task методы решают это естественным путем, помещая исключение в возвращаемую задачу. async void методы неестественны, и им негде разместить исключение, поэтому они поднимают его непосредственно на SynchronizationContext, который был текущим на момент запуска метода.

В вашем приложении это контекст пользовательского интерфейса, так что он точно так же, как и в обработчике событий. В вашем модульном тесте нет контекста, поэтому он накладывается на поток потока потока. I think Поведение NUnit в этой ситуации состоит в том, чтобы поймать исключение и выгрузить его на консоль.

Лично я предпочитаю использовать мой собственный асинхронно-совместимый ICommand, такие как AsyncCommand в моем Mvvm.Async library (также мою статью о asynchronous MVVM commands):

new AsyncCommand(_ => RefreshAsync()) 

, который затем может быть, естественно, испытанный блок:

await vm.RefreshCommand.ExecuteAsync(null); // does not throw 

В качестве альтернативы вы можете указать свой собственный код синхронизации в модульном тесте (используя, например,, my AsyncContext):

// Arrange 
webService = new Mock<IServerApi>(); 
webService.Setup(mock => mock.GetProjectsAsync()) 
    .ThrowsAsync(new TaskCanceledException()); 
vm = new ProjectListViewModel(webService.Object); 

// Act/Assert 
AsyncContext.Run(() => vm.RefreshCommand.Execute(null)); 

В этом случае, если есть исключение, Run будет распространяться его.

+0

Спасибо! Я посмотрю на вашу библиотеку. – cmeeren

+1

'... преобразуется в метод async void компилятором.' Это потому, что подпись 'Command' ожидает' Action'? –

+0

@ KennethK .: Да. –

0

Поскольку async void (что является обработчиком вашей команды), в основном «огонь и забудьте», и вы не можете ждать его в тесте, я бы предложил модульное тестирование метода RefreshAsync() (вы можете захотеть чтобы сделать его внутренний или общественный), то это можно легко сделать в NUnit:

если вы утверждаете исключения бросают:

[Test] 
public async Task Test_Exception_RefreshAsync(){ 
     try 
     { 
      await vm.RefreshAsync(); 
      Assert.Fail("No exception was thrown"); 
     } 
     catch (NotImplementedException e) 
     { 
      // Pass 
     } 
} 

или просто

[Test] 
public async Task Test_RefreshAsync(){ 
    var vm = new ProjectListViewModel(...); 
    await vm.RefreshAsync(); 
    //Assertions here 
} 

или как другое состояние ответа, вы можете создать свой собственный AsyncCommand, который вы можете ожидать.

+0

Спасибо за предложение, но я бы предпочел не подвергать тестированию частные методы. В общем, это детали реализации и склонны к изменению. – cmeeren

+0

Правильно, это (спорный) компромисс, что связано с тем, что он проще, чем использование новой сторонней библиотеки. – brakeroo