Как провести модульное тестирование WPF + TPL

Я запускаю простое приложение WPF, которое использует подход на основе событий /TPL для обработки данных. В этом примере используются три класса (View, Presenter, Model)

Snip of Presenter:

internal void btn_test_Click(object sender, EventArgs e)
{
    Task<Person>.Factory.StartNew(() => GetPerson(id)).ContinueWith(UpdateTest, TaskScheduler.FromCurrentSynchronizationContext());
}

public Person GetPerson(int id)
{
    Person p = Model.GetPerson(id);
    return p;
}

private void UpdateTest(Task<Person> task)
{
    Person p = task.Result;
    window.tb_test.Text = p.ID + " " + p.Name; // PROBLEM HERE
}

Итак, я получаю событие из представления, запускаю новую задачу для получения данных из моей БД или службы и обновляю пользовательский интерфейс впоследствии. Работают отлично.

Теперь я хочу создать юнит-тест для этого сценария. Является ли отображаемое значение правильным?

[TestMethod]

SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());

waitHandle = new ManualResetEvent(false);

WPF.MainWindowView mwv = new MainWindowView();
mwv.btn_test.RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent));
mwv.tb_test.TextChanged += (s, e) => waitHandle.Set();

waitHandle.WaitOne();

Assert.AreEqual("43 displayvalue", mwv.tb_test.Text);

Приложение WPF работает нормально, но на тестах есть исключение InvalidoperationException. Я пытался использовать Dispatcher для обновления компонентов пользовательского интерфейса, вызвав

window.tb_test.Dispatcher.BeginInvoke((ThreadStart)delegate {window.tb_test.Text = t.ID + " " + t.Name});

в UpdateTest, но событие "tb_test.textChanged" не вызывается в моем модуле тестирования, хотя само приложение работает отлично.

2 ответа

Решение

При условии, что window это WPF Window а также tb_test это WPF TextBlock,

Во-первых, позвольте мне предвосхитить все это, сказав, что модель потоков WPF немного усложняет запуск модульных тестов для живых объектов WPF. Лично я считаю, что преимущества этих видов кодированных тестов минимальны по сравнению с трудностями, связанными со всем этим, особенно при следовании шаблону проектирования MVVM. Перемещение важной логики в более удобные для тестирования местоположения (читай: привязка данных и команды, управляющие объектами модели представления) сделало бы эти тесты намного более избыточными.

Если вы можете реструктурировать свой дизайн так, чтобы вы могли получить всю "важную" логику в более удобном для тестирования месте, то я бы порекомендовал это попробовать. Я не знаю, какова история этой кодовой базы, поэтому я не хочу на этом останавливаться. Если вы не можете, и / или если вам просто любопытно, тогда давайте спустимся в кроличью нору...

Когда вы устанавливаете текущий SynchronizationContext в тесте вы используете простой SynchronizationContext, чья Post реализуется с использованием ThreadPool (т.е. обратный вызов может быть выполнен в любом потоке). Итак, когда TaskScheduler.FromCurrentSynchronizationContext идет, чтобы запланировать продолжение задачи, у него есть "обновить TextBlock"s Text"код, выполняемый в том, что может быть другим потоком, ломая WPF", должен быть выполнен на Dispatcher нить "правило.

Ваше предлагаемое исправление для использования Dispatcher.BeginInvoke вероятно, решит вашу непосредственную проблему, если Dispatcher бежали. Я не вижу Dispatcher.Run или же Dispacher.PushFrame где-нибудь в опубликованном тестовом коде, так что я думаю, что это эффективно превращает все, что будет выполнено на этом Dispatcher в no-op (идет в очередь, из которой никогда не читают). Когда приложение работает нормально, код, который Visual Studio автоматически генерирует для вас, вызывает Application.Run в конце точки входа исполняемого файла, которая (в конце концов) доходит до вызова Dispatcher.Run для вас, чтобы он мог начать обработку сообщений, таких как "отображать главное окно" и тому подобное.

Вы, вероятно, заметите, что после звонка Dispatcher.Runон блокирует диспетчер в любом потоке, к которому вы его вызываете, пока вы не скажете ему завершить работу из другого потока. После того, как он сказал, чтобы выключить, нет никакого способа запустить другой Dispatcher в этом потоке... так что, по сути, либо каждый тест должен вращаться и вращаться в своем собственном отдельном потоке (раздражающе медленно, если вы хотите написать больше, чем несколько таких тестов, по крайней мере для меня), или вы вы, возможно, выиграете от использования фантазии [AssemblyInitialize] / [AssemblyCleanup] методы для MSTest, так что вы можете управлять только одним Dispatcher насос для всех тестов в этом проекте (что мы и сделали).

Пройдя через это, вы, вероятно, также узнаете, что mwv.tb_test.Text в тесте должно произойти на Dispatcher как нить.

Вы также рискуете состоянием гонки, поскольку RaiseEvent может (в зависимости от того, как вы подходите к проблемам потоков) завершиться до TextChanged обработчик подключен в вашем тесте, это означает, что ManualResetEvent иногда может навсегда блокировать даже после всего остального.

Как упоминалось в ответе Джо, вам нужен диспетчер, работающий в потоке, чтобы это работало.

Посмотрите этот ответ для кода в модульном тесте, который должен работать: Task.ContinueWith и DispatcherSynchronizationContext

Другие вопросы по тегам