Модульное тестирование агента
Я пытаюсь проверить MailboxProcessor в F#. Я хочу проверить, что функция f, которую я даю, фактически выполняется при публикации сообщения.
Оригинальный код использует Xunit, но я сделал для него fsx, который я могу выполнить с помощью fsharpi.
Пока я делаю это:
open System
open FSharp
open System.Threading
open System.Threading.Tasks
module MyModule =
type Agent<'a> = MailboxProcessor<'a>
let waitingFor timeOut (v:'a)=
let cts = new CancellationTokenSource(timeOut|> int)
let tcs = new TaskCompletionSource<'a>()
cts.Token.Register(fun (_) -> tcs.SetCanceled()) |> ignore
tcs ,Async.AwaitTask tcs.Task
type MyProcessor<'a>(f:'a->unit) =
let agent = Agent<'a>.Start(fun inbox ->
let rec loop() = async {
let! msg = inbox.Receive()
// some more complex should be used here
f msg
return! loop()
}
loop()
)
member this.Post(msg:'a) =
agent.Post msg
open MyModule
let myTest =
async {
let (tcs,waitingFor) = waitingFor 5000 0
let doThatWhenMessagepostedWithinAgent msg =
tcs.SetResult(msg)
let p = new MyProcessor<int>(doThatWhenMessagepostedWithinAgent)
p.Post 3
let! result = waitingFor
return result
}
myTest
|> Async.RunSynchronously
|> System.Console.WriteLine
//display 3 as expected
Этот код работает, но он не выглядит хорошо для меня.
1) нормально ли используется TaskCompletionSource в F# или есть что-то, что позволяет мне ждать завершения?
2) Я использую второй аргумент в функции waitFor, чтобы ограничить его, я знаю, что мог бы использовать тип MyType<'a>(), чтобы сделать это, есть ли другой вариант? Я бы предпочел не использовать новый MyType, который я считаю громоздким.
3) Есть ли другой вариант для проверки моего агента, кроме как сделать это? единственный пост на эту тему, который я нашел на данный момент, - это пост блога за 2009 год http://www.markhneedham.com/blog/2009/05/30/f-testing-asynchronous-calls-to-mailboxprocessor/
1 ответ
Это сложный вопрос, я уже некоторое время пытаюсь заняться этим. Это то, что я нашел до сих пор, это слишком долго для комментария, но я бы не решался назвать его полным ответом либо...
От самого простого к самому сложному, на самом деле зависит, насколько тщательно вы хотите протестировать и насколько сложна логика агента.
Ваше решение может быть в порядке
То, что у вас есть, подходит для небольших агентов, чья единственная роль заключается в сериализации доступа к асинхронному ресурсу, практически без внутренней обработки состояния. Если вы предоставите f
как вы делаете в своем примере, вы можете быть уверены, что он будет вызван за относительно короткий промежуток времени в несколько сотен миллисекунд. Конечно, это кажется неуклюжим и вдвое превышает размер кода для всех оболочек и помощников, но их можно использовать повторно, если вы тестируете больше агентов и / или больше сценариев, поэтому стоимость амортизируется довольно быстро.
Проблема, с которой я сталкиваюсь, заключается в том, что это не очень полезно, если вы также хотите проверить больше, чем вызываемая функция - например, состояние внутреннего агента после вызова.
Одно замечание, которое применимо и к другим частям ответа: я обычно запускаю агентов с токеном отмены, это облегчает жизненный цикл как производства, так и тестирования.
Использовать каналы ответа агента
добавлять AsyncReplyChannel<'reply>
на тип сообщения и публиковать сообщения с помощью PostAndAsyncReply
вместо Post
метод на агента. Это изменит вашего агента на что-то вроде этого:
type MyMessage<'a, 'b> = 'a * AsyncReplyChannel<'b>
type MyProcessor<'a, 'b>(f:'a->'b) =
// Using the MyMessage type here to simplify the signature
let agent = Agent<MyMessage<'a, 'b>>.Start(fun inbox ->
let rec loop() = async {
let! msg, replyChannel = inbox.Receive()
let! result = f msg
// Sending the result back to the original poster
replyChannel.Reply result
return! loop()
}
loop()
)
// Notice the type change, may be handled differently, depends on you
member this.Post(msg:'a): Async<'b> =
agent.PostAndAsyncReply(fun channel -> msg, channel)
Это может показаться искусственным требованием для "интерфейса" агента, но это удобно для имитации вызова метода и тривиально для тестирования - дождитесь PostAndAsyncReply
(с таймаутом), и вы можете избавиться от большей части вспомогательного кода теста.
Поскольку у вас есть отдельный вызов предоставленной функции и replyChannel.Reply
ответ также может отражать состояние агента, а не только результат функции.
Тестирование на основе модели черного ящика
Это то, о чем я буду говорить подробнее, так как я думаю, что это наиболее общее.
В случае, если агент инкапсулирует более сложное поведение, я нашел удобным пропустить тестирование отдельных сообщений и использовать основанные на модели тесты для проверки целых последовательностей операций на модели ожидаемого внешнего поведения. Я использую FsCheck.Experimental API для этого:
В вашем случае это было бы выполнимо, но не имело бы большого смысла, так как не существует внутреннего состояния для моделирования. Чтобы дать вам пример того, как это выглядит в моем конкретном случае, рассмотрим агент, который поддерживает клиентские соединения WebSocket для отправки сообщений клиентам. Я не могу поделиться всем кодом, но интерфейс выглядит так
/// For simplicity, this adapts to the socket.Send method and makes it easy to mock
type MessageConsumer = ArraySegment<byte> -> Async<bool>
type Message =
/// Send payload to client and expect a result of the operation
| Send of ClientInfo * ArraySegment<byte> * AsyncReplyChannel<Result>
/// Client connects, remember it for future Send operations
| Subscribe of ClientInfo * MessageConsumer
/// Client disconnects
| Unsubscribe of ClientInfo
Внутренне агент поддерживает Map<ClientInfo, MessageConsumer>
,
Теперь для тестирования этого я могу смоделировать внешнее поведение с точки зрения неформальной спецификации, такой как: "отправка подписанному клиенту может быть успешной или неудачной в зависимости от результата вызова функции MessageConsumer" и "отправка неподписанному клиенту не должна вызывать какие-либо MessageConsumer". Таким образом, я могу определить типы, например, для моделирования агента.
type ConsumerType =
| SucceedingConsumer
| FailingConsumer
| ExceptionThrowingConsumer
type SubscriptionState =
| Subscribed of ConsumerType
| Unsubscribed
type AgentModel = Map<ClientInfo, SubscriptionState>
А затем используйте FsCheck.Experimental, чтобы определить операции добавления и удаления клиентов с по-разному успешными потребителями и попытки отправки данных им. Затем FsCheck генерирует случайные последовательности операций и проверяет реализацию агента по модели между каждым шагом.
Это требует некоторого дополнительного кода "только для тестирования" и вначале требует значительных интеллектуальных затрат, но позволяет тестировать относительно сложную логику с состоянием. Что мне особенно нравится в этом, так это то, что он помогает мне тестировать весь контракт, а не только отдельные функции / методы / сообщения, точно так же, как основанное на свойствах / генеративное тестирование помогает тестировать не только с одним значением.
Использовать актеров
Я еще не зашел так далеко, но то, что я слышал в качестве альтернативы, это использование, например, Akka.NET для полноценной поддержки модели актера, и использование его средств тестирования, которые позволяют запускать агенты в специальных тестовых контекстах, проверять ожидаемые сообщения и так далее. Как я уже сказал, у меня нет опыта из первых рук, но он кажется жизнеспособным вариантом для более сложной логики с сохранением состояния (даже на одной машине, а не в распределенной многоузловой системе акторов).