使用Rx,Xamarin进行非确定性单元测试

时间:2016-10-15 13:18:59

标签: c# unit-testing xamarin system.reactive non-deterministic

所以我在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>>

我使用ObservableSubscribe代替返回Task<IEnumerable<Departament>>的简单方法,因为在我的情况下Observable将&#34;返回&#34;两次(一次是来自缓存的数据,另一次是新获取的数据,来自web)。

对于测试我当然是模拟_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());
}

问题是该测试随机失败(大约10次中有1次)。基本上视图模型中的Departaments集合应在Observable&#34;返回&#34;时更新。是空的。

我应该补充一点,我在Xamarin Studio中使用xUnit 2.1.0测试框架和xUnit控制台。

修改 只有在测试运行器中运行时,Enigmativity的建议才会抛出 Sequence不包含任何元素异常。下面是用于演示该问题的最小woking示例代码:

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

3 个答案:

答案 0 :(得分:2)

您是否打算在进行此刷新时阻止某些GUI。我是指通过显示微调器或其他非确定性进度条阻止用户进度?或者您是否希望用户能够继续做他们正在做的事情,但能够发出信号表明有一些后台进程正在发生,并在该过程完成时发出信号?

对于前者,我认为你拥有的当前签名是明智的,即你返回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列表中的项目,而不是一个大块,
  • 不混合TaskIObservable<T>
  • 有一个地方可以指示并发模型。似乎有一些东西在您的程序中切换不在您的代码中的线程,因为您有一个ObserveOn但没有匹配的SubscribeOn

修改

这是我要创建此视图模型的样式。我不想混合Task和IObservable。我也赞成使用Scheduler over Synchronization上下文。我添加了资源管理,以便订阅不重叠(如果多次调用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; }
}

答案 1 :(得分:0)

您的代码正在为可观察对象创建两个订阅 - 有时departamentsObservable.ObserveOn(SynchronizationContext.Current).Subscribe(...)会在 await departamentsObservable.FirstOrDefaultAsync()完成之前生成值,有时不会&# 39;吨

两个订阅意味着源运行的两个独立时间。当await快速完成时,您的其他订阅不会被调用(或在调用Assert.Equal后调用),因此值不会添加到列表中。

请改为尝试:

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

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

现在您只有一个订阅等待生成的最后一个值。

如果您只期望一个值,并且可观察量不会自然结束,那么最后会弹出一个.Take(1)

答案 2 :(得分:0)

@lee-campbell的答案很棒,但缺少Xamarin Forms实现的ISchedulerProvider

以下是我使用的内容:

public sealed class SchedulerProvider : ISchedulerProvider
    {
        public IScheduler Foreground => new SynchronizationContextScheduler(SynchronizationContext.Current);
        public IScheduler Background => TaskPoolScheduler.Default;
    }