Как провести модульное тестирование 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