Недетерминированные модульные тесты с Rx, Xamarin

Поэтому я пишу модульные тесты для одной из моих моделей представления в мобильном приложении Xamarin. Метод, который я тестирую, выглядит так:

public async Task RefreshItems()
{
    var departamentsObservable = _dataService.GetDepartaments();

    departamentsObservable.ObserveOn(SynchronizationContext.Current).Subscribe(items =>
    {
        Departaments.ReplaceWithRange(items);
    });

    await departamentsObservable.FirstOrDefaultAsync();
}

Метод _dataService.GetDepartaments(); возвращает IObservable<IEnumerable<Departament>>.

Я использую Observable и Subscribe вместо простого метода, который возвращает Task<IEnumerable<Departament>>, потому что в моем случае Observable "вернется" дважды (один раз с данными из кеша, а другой раз с недавно полученными данными из сети).

Для тестирования я, конечно, издеваюсь над методом _dataService.GetDepartaments(); следующим образом:

public IObservable<IEnumerable<Departament>> GetDepartaments()
{
    return Observable.Return(MockData.Departaments);
}

Таким образом, метод немедленно возвращает фиктивные данные.

И мой тест для метода RefreshItems выглядит так:

[Fact]
public async Task RefreshItemsTest()
{
    await _viewModel.RefreshItems();

    Assert.Equal(MockData.Departaments, _viewModel.Departaments, 
                 new DepartamentComparer());
}

Проблема в том, что этот тест случайным образом терпит неудачу (примерно 1 из 10 раз). В основном коллекция Departaments в модели представления, которая должна обновляться, когда Observable "возвращает" пуста.

Я должен добавить, что я использую тестовую среду xUnit 2.1.0 и средство запуска консоли xUnit в Xamarin Studio.

EDIT: Предложение Enigmativity создает исключение Последовательность не содержит элементов только при запуске в средстве выполнения тестов. Ниже приведен минимальный пример кода для демонстрации проблемы:

using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using System.Reactive.Linq;
using System.Threading;
using System.Collections.Generic;

namespace TestApp
{
    public class TestViewModel
    {
        public ObservableCollection<TestDepartament> Departaments { get; set; }

        private ITestDataService _dataService;

        public TestViewModel(ITestDataService dataService)
        {
            _dataService = dataService;
            Departaments = new ObservableCollection<TestDepartament>();
        }

        public async Task RefreshItems()
        {
            var facultiesObservable = _dataService.GetDepartaments();

            await facultiesObservable.ObserveOn(SynchronizationContext.Current).Do(items =>
            {
                Departaments.Clear();
                foreach(var item in items)
                    Departaments.Add(item);
            });
        }
    }

    public interface ITestDataService
    {
        IObservable<IEnumerable<TestDepartament>> GetDepartaments();
    }

    public class MockDataService : ITestDataService
    {
        public IObservable<IEnumerable<TestDepartament>> GetDepartaments()
        {
            return Observable.Return(TestMockData.Departaments);
        }
    }

    public static class TestMockData
    {
        public static List<TestDepartament> Departaments
        {
            get
            {
                var departaments = new List<TestDepartament>();

                for (int i = 0; i < 15; i++)
                {
                    departaments.Add(new TestDepartament
                    {
                        Name = $"Departament {i}",
                        ImageUrl = $"departament_{i}_image_url",
                        ContentUrl = $"departament_{i}_content_url",
                    });
                }

                return departaments;
            }
        }
    }

    public class TestDepartament
    {
        public string ContentUrl { get; set; }
        public string Name { get; set; }
        public string ImageUrl { get; set; }
    }
}

А это тест xUnit:

public class DepartamentsViewModelTests
{
    private readonly TestViewModel _viewModel;

    public DepartamentsViewModelTests()
    {
        var dataService = new MockDataService();
        _viewModel = new TestViewModel(dataService);
    }

    [Fact]
    public async Task RefreshItemsTest()
    {
        await _viewModel.RefreshItems();

        Assert.Equal(TestMockData.Departaments, _viewModel.Departaments);
    }
}

person Andrius    schedule 15.10.2016    source источник
comment
Вы отлаживали и сравнивали результаты вручную? Возможно, DepartmentComparer неисправен?   -  person myermian    schedule 15.10.2016
comment
@m-y Спасибо за ваш ответ. Да, я отладил свой тест, и длина коллекции Departaments в модели представления действительно равна 0, когда тест не пройден.   -  person Andrius    schedule 15.10.2016


Ответы (3)


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

Для первого я бы подумал, что текущая подпись, которая у вас есть, разумна, то есть вы возвращаете Task. Однако реализацию, я думаю, можно улучшить.

public async Task RefreshItems()
{
    var items = await _dataService.GetDepartaments()
        .Take(1)
        .ObserveOn(SynchronizationContext.Current)
        .ToTask();

    Departaments.ReplaceWithRange(items);
}

Обратите внимание на Take(1). Если вы попытаетесь преобразовать IObservable<T> в Task<T>/Task, вы получите только последнее значение или уступите, когда последовательность будет завершена. Без Take(1) мы могли бы просто ждать вечно. Однако я думаю, что у вас есть сценарий, который вы загружаете из кеша, поэтому вы можете получить 0,1 или 2 вызова OnNext. В этом сценарии я не знаю, чего достигает ожидание последнего значения? Также отмечу отсутствие обработки ошибок.

Я бы, наверное, в своем собственном коде сделал что-то вроде этого FWIW

public void RefreshItems()
{
    Departments.Clear();
    _state = States.Processing();
    var items =  _dataService.GetDepartaments()
        .SubscribeOn(_schedulerProvider.Background)
        .ObserveOn(_schedulerProvider.Foreground)
        .Subscribe(
            item=> Departaments.Add(item),
            ex => _state = States.Faulted(ex),
            () => _state = States.Idle());
}

Это позволяет

  • поле/свойство _states для отображения того, что происходит в данный момент (обработка, бездействие или какая-то ошибка),
  • элементы будут добавляться в список Departments по мере поступления, а не одним большим блоком,
  • Не смешивает Task и IObservable<T>
  • Имеет единственное место для определения модели параллелизма. Похоже, что есть что-то, что переключает потоки в вашей программе, чего нет в вашем коде, поскольку у вас есть ObserveOn, но нет соответствующего SubscribeOn.

Изменить

Вот стиль, в котором я хотел бы создать эту модель представления. Я предпочитаю не смешивать Task и IObservable. Я также предпочитаю использовать планировщики, а не контексты синхронизации. Я добавил управление ресурсами, чтобы подписки не перекрывались (если Refresh вызывается несколько раз) и чтобы его можно было отменить после завершения ViewModel. Здесь должно быть легко проверить 0,1 или много значений. Это также позволяет тестировать сценарии ошибок (например, OnError через тайм-ауты, сбои в сети и т. д.).

Я также просто для удовольствия добавил свойство Async State, чтобы внешние потребители могли видеть, что ViewModel в данный момент что-то обрабатывает.

Однако я перегружаю тест, чтобы иметь много проблем. Я, вероятно, не стал бы делать это на практике, но я думаю, что это упрощает чтение для SO.

http://share.linqpad.net/67hmc2.linq

void Main()
{
    var schedulerProvider = new TestSchedulerProvider();

    var cachedData = Enumerable.Range(0,3).Select(i => new TestDepartament
    {
        Name = $"Departament {i}",
        ImageUrl = $"departament_{i}_image_url",
        ContentUrl = $"departament_{i}_content_url",
    }).ToArray();

    var liveData = Enumerable.Range(10, 5).Select(i => new TestDepartament
    {
        Name = $"Departament {i}",
        ImageUrl = $"departament_{i}_image_url",
        ContentUrl = $"departament_{i}_content_url",
    }).ToArray();

    var data = schedulerProvider.Background.CreateColdObservable<IEnumerable<TestDepartament>>(
        ReactiveTest.OnNext<IEnumerable<TestDepartament>>(100, cachedData),
        ReactiveTest.OnNext<IEnumerable<TestDepartament>>(3000, liveData),
        ReactiveTest.OnCompleted<IEnumerable<TestDepartament>>(3000));

    var dataService = Substitute.For<ITestDataService>();
    dataService.GetDepartaments().Returns(data);

    var viewModel = new TestViewModel(dataService, schedulerProvider);

    Assert.Equal(AsyncState.Idle, viewModel.State);

    viewModel.RefreshItems();
    Assert.Equal(AsyncState.Processing, viewModel.State);

    schedulerProvider.Background.AdvanceTo(110);
    schedulerProvider.Foreground.Start();

    Assert.Equal(cachedData, viewModel.Departments);

    schedulerProvider.Background.Start();
    schedulerProvider.Foreground.Start();

    Assert.Equal(liveData, viewModel.Departments);
    Assert.Equal(AsyncState.Idle, viewModel.State);
}

// Define other methods and classes here
public class TestViewModel : INotifyPropertyChanged, IDisposable
{
    private readonly ITestDataService _dataService;
    private readonly ISchedulerProvider _schedulerProvider;
    private readonly SerialDisposable _refreshSubscription = new SerialDisposable();
    private AsyncState _state = AsyncState.Idle;

    public ObservableCollection<TestDepartament> Departments { get;} = new ObservableCollection<UserQuery.TestDepartament>();
    public AsyncState State
    {
        get { return _state; }
        set
        {
            _state = value;
            OnPropertyChanged(nameof(State));
        }
    }


    public TestViewModel(ITestDataService dataService, ISchedulerProvider schedulerProvider)
    {
        _dataService = dataService;
        _schedulerProvider = schedulerProvider;
    }

    public void RefreshItems()
    {
        Departments.Clear();
        State = AsyncState.Processing;
        _refreshSubscription.Disposable = _dataService.GetDepartaments()
            .SubscribeOn(_schedulerProvider.Background)
            .ObserveOn(_schedulerProvider.Foreground)
            .Subscribe(
                items =>
                {
                    Departments.Clear();
                    foreach (var item in items)
                    {
                        Departments.Add(item);  
                    }
                },
                ex => State = AsyncState.Faulted(ex.Message),
                () => State = AsyncState.Idle);
    }

    #region INotifyPropertyChanged implementation

    public event PropertyChangedEventHandler PropertyChanged;

    private void OnPropertyChanged(string propertyName)
    {
        var handler = PropertyChanged;
        if (handler != null) handler(this, new PropertyChangedEventArgs(propertyName));
    }

    #endregion

    public void Dispose()
    {
        _refreshSubscription.Dispose();
    }
}

public interface ITestDataService
{
    IObservable<IEnumerable<TestDepartament>> GetDepartaments();
}

public interface ISchedulerProvider
{
    IScheduler Foreground { get;}
    IScheduler Background { get;}
}
public class TestSchedulerProvider : ISchedulerProvider
{
    public TestSchedulerProvider()
    {
        Foreground = new TestScheduler();
        Background = new TestScheduler();
    }
    IScheduler ISchedulerProvider.Foreground { get { return Foreground; } }
    IScheduler ISchedulerProvider.Background { get { return Background;} }
    public TestScheduler Foreground { get;}
    public TestScheduler Background { get;}
}
public sealed class AsyncState
{
    public static readonly AsyncState Idle = new AsyncState(false, null);
    public static readonly AsyncState Processing = new AsyncState(true, null);

    private AsyncState(bool isProcessing, string errorMessage)
    {
        IsProcessing = isProcessing;
        IsFaulted = string.IsNullOrEmpty(errorMessage);
        ErrorMessage = ErrorMessage;
    }

    public static AsyncState Faulted(string errorMessage)
    {
        if(string.IsNullOrEmpty(errorMessage))
            throw new ArgumentException();
        return new AsyncState(false, errorMessage);
    }

    public bool IsProcessing { get; }
    public bool IsFaulted { get; }
    public string ErrorMessage { get; }
}

public class TestDepartament
{
    public string ContentUrl { get; set; }
    public string Name { get; set; }
    public string ImageUrl { get; set; }
}
person Lee Campbell    schedule 17.10.2016
comment
Спасибо за ответ, Ли. Я использую фреймворк Akavache и его метод GetAndFetchLatest. Таким образом, наблюдаемое может возвращаться 0, 1 или 2 раза. Я показываю счетчик пользователю, пока данных нет вообще. Когда какие-либо данные (локальные или извлеченные) поступают и заполняют наблюдаемую коллекцию Departments, я удаляю счетчик. Если данные поступают во второй раз (т. е. сначала локальные, а затем новые), я автоматически обновляю Departaments наблюдаемую коллекцию. В течение всего процесса пользовательский интерфейс не блокируется ни разу, поэтому пользователь может выполнять любые действия в любое время. Я все еще новичок в Rx, и я попробую ваше предложение. Спасибо. - person Andrius; 17.10.2016
comment
@ Ли - я попробовал ваш код, и он работает. В вашем примере вы await выполняете все выражение, но эта перегрузка Subscribe возвращает IDisposable, поэтому я думаю, что это опечатка. Я прав? Если да, то это имеет одну проблему - метод RefreshItems становится невозможным для тестирования, так как нечего await. Моя логика в чем-то ошибочна? - person Andrius; 17.10.2016
comment
Правильный. У меня там была неверная инструкция await (вы не можете ждать подписки). Это полностью тестируемо, так как вы теперь используете только Rx, поэтому можете положиться на библиотеку тестирования. - person Lee Campbell; 17.10.2016
comment
Что вы имеете в виду, говоря, что опираетесь на библиотеку тестирования? Как мне написать тест, который гарантирует, что метод RefreshItems действительно заполняет коллекцию Departments? - person Andrius; 17.10.2016
comment
Обновлен ответ, чтобы иметь некоторый код, показывающий, что я имею в виду. - person Lee Campbell; 18.10.2016
comment
Оказывается, я действительно могу все проверить, контролируя течение времени с помощью TestScheduler. Мне еще многое предстоит узнать о Rx, но ваши идеи очень полезны. Очень признателен. - person Andrius; 18.10.2016
comment
Отличный ответ, ОДНАКО, как тогда вы реализуете ISchedulerProvider для Xamarin Forms? - person François; 08.03.2018
comment
Я не уверен, почему это было бы иначе. Возможно, нужно спросить эксперта по xamarin - person Lee Campbell; 08.03.2018

Ваш код создает две подписки на наблюдаемое: иногда departamentsObservable.ObserveOn(SynchronizationContext.Current).Subscribe(...) создает значения до завершения await departamentsObservable.FirstOrDefaultAsync(), а иногда нет.

Две подписки означают два независимых времени выполнения источника. Когда await завершается быстро, другая ваша подписка не вызывается (или вызывается после вызова вашей Assert.Equal), и, следовательно, значения не добавляются в список.

Попробуйте это вместо этого:

public async Task RefreshItems()
{
    var departamentsObservable = _dataService.GetDepartaments();

    await departamentsObservable.ObserveOn(SynchronizationContext.Current).Do(items =>
    {
        Departaments.ReplaceWithRange(items);
    });
}

Теперь у вас есть только одна подписка, ожидающая создания последнего значения.

Если вы ожидаете только одно значение, а наблюдаемое не заканчивается естественным образом, вставьте .Take(1) в конце.

person Enigmativity    schedule 16.10.2016
comment
Большое спасибо за ваше объяснение. Я заменил свой код вашим предложением, но получаю исключение Последовательность не содержит элементов. - person Andrius; 16.10.2016
comment
@ Андриус ​​- откуда у тебя эта ошибка? Можете ли вы добавить код в конец вашего вопроса? - person Enigmativity; 16.10.2016
comment
Я получаю исключение в этой строке в моей модели представления: await departamentsObservable.ObserveOn(SynchronizationContext.Current).Do(items => Дайте мне знать, если вам нужен еще код. - person Andrius; 16.10.2016
comment
Исключение, по-видимому, выдается только тогда, когда мой код запускается в тестовом бегуне (я пробовал бегуны xUnit и NUnit). Когда я запускаю свое приложение на реальном устройстве, все работает правильно, независимо от того, использую ли я макет _dataService.GetDepartaments(); (который мгновенно возвращается, как показано в вопросе) или фактическую реализацию. Я чувствую себя очень потерянным в этот момент :) - person Andrius; 16.10.2016
comment
@Andrius Андриус ​​- Было бы здорово, если бы вы добавили код в конец своего вопроса, чтобы я мог скопировать, вставить и запустить, чтобы самому увидеть ошибку. - person Enigmativity; 17.10.2016
comment
конечно, я удалил все ненужные зависимости и создал минимальный рабочий пример внизу моего вопроса. Вы должны иметь возможность просто скопировать и вставить код. Позвольте мне знать, если вам нужно что-нибудь еще. - person Andrius; 17.10.2016

Ответ @lee-campbell отличный, но в нем отсутствует реализация Xamarin Forms его ISchedulerProvider.

Вот что я использую:

public sealed class SchedulerProvider : ISchedulerProvider
    {
        public IScheduler Foreground => new SynchronizationContextScheduler(SynchronizationContext.Current);
        public IScheduler Background => TaskPoolScheduler.Default;
    }
person François    schedule 08.03.2018