Skip to content

Commit

Permalink
Add scaladoc to example DSL
Browse files Browse the repository at this point in the history
  • Loading branch information
Aleksei Shashev committed Aug 3, 2023
1 parent f0f7d71 commit 02724de
Show file tree
Hide file tree
Showing 7 changed files with 323 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,184 @@ import org.scalactic.source

import ru.tinkoff.tcb.mockingbird.edsl.model.*

/**
* ==Описание набора примеров==
*
* `ExampleSet` предоставляет DSL для описания примеров взаимодействия с Mockingbird со стороны внешнего
* приложения/пользователя через его API. Описанные примеры потом можно в Markdown описание последовательности действий
* с примерами HTTP запросов и ответов на них или сгенерировать тесты для scalatest. За это отвечают интерпретаторы DSL
* [[ru.tinkoff.tcb.mockingbird.edsl.interpreter.MarkdownGenerator MarkdownGenerator]] и
* [[ru.tinkoff.tcb.mockingbird.edsl.interpreter.AsyncScalaTestSuite AsyncScalaTestSuite]] соответственно.
*
* Описание набора примеров может выглядеть так:
*
* {{{
* package ru.tinkoff.tcb.mockingbird.examples
*
* import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet
* import ru.tinkoff.tcb.mockingbird.edsl.model.*
* import ru.tinkoff.tcb.mockingbird.edsl.model.Check.*
* import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.*
* import ru.tinkoff.tcb.mockingbird.edsl.model.ValueMatcher.syntax.*
*
* class CatsFacts[HttpResponseR] extends ExampleSet[HttpResponseR] {
*
* override val name = "Примеры использования ExampleSet"
*
* example("Получение случайного факта о котиках")(
* for {
* _ <- describe("Отправить GET запрос")
* resp <- sendHttp(
* method = Get,
* path = "/fact",
* headers = Seq("X-CSRF-TOKEN" -> "unEENxJqSLS02rji2GjcKzNLc0C0ySlWih9hSxwn")
* )
* _ <- describe("Ответ содержит случайный факт полученный с сервера")
* _ <- checkHttp(
* resp,
* HttpResponseExpected(
* code = Some(CheckInteger(200)),
* body = Some(
* CheckJsonObject(
* "fact" -> CheckJsonString("There are approximately 100 breeds of cat.".sample),
* "length" -> CheckJsonNumber(42.sample)
* )
* ),
* headers = Seq("Content-Type" -> CheckString("application/json"))
* )
* )
* } yield ()
* )
* }
* }}}
*
* Дженерик параметр `HttpResponseR` нужен так результат выполнения HTTP запроса зависит от интерпретатора DSL.
*
* Переменная `name` - общий заголовок для примеров внутри набора, при генерации Markdown файла будет добавлен в самое
* начало как заголовок первого уровня.
*
* Метод `example` позволяет добавить пример к набору. Вначале указывается название примера, как первый набор
* аргументов. При генерации тестов это будет именем теста, а при генерации Markdown будет добавлено как заголовок
* второго уровня, затем описывается сам пример. Последовательность действий описывается при помощи монады
* [[ru.tinkoff.tcb.mockingbird.edsl.Example Example]].
*
* `ExampleSet` предоставляет следующие действия:
* - [[describe]] - добавить текстовое описание.
* - [[sendHttp]] - исполнить HTTP запрос с указанными параметрами, возвращает результат запроса.
* - [[checkHttp]] - проверить, что результат запроса отвечает указанным ожиданиям, возвращает извлеченные из ответа
* данные на основании проверок. ''Если предполагается использовать какие-то части ответа по ходу описания примера,
* то необходимо для них задать ожидания, иначе они будут отсутствовать в возвращаемом объекте.''
*
* Для описания ожиданий используются проверки [[model.Check$]]. Некоторые проверки принимают как параметр
* [[model.ValueMatcher ValueMatcher]]. Данный трейт тип представлен двумя реализациями
* [[model.ValueMatcher.AnyValue AnyValue]] и [[model.ValueMatcher.FixedValue FixedValue]]. Первая описывает
* произвольное значение определенного типа, т.е. проверки значения не производится. Вторая задает конкретное ожидаемое
* значение.
*
* Для упрощения создания значений типа [[model.ValueMatcher ValueMatcher]] добавлены имплиситы в объекте
* [[model.ValueMatcher.syntax ValueMatcher.syntax]]. Они добавляют неявную конвертацию значений в тип
* [[model.ValueMatcher.FixedValue FixedValue]], а так же методы `sample` и `fixed` для создания
* [[model.ValueMatcher.AnyValue AnyValue]] и [[model.ValueMatcher.FixedValue FixedValue]] соответственно. Благодаря
* этому можно писать:
* {{{
* CheckString("some sample".sample) // вместо CheckString(AnyValue("some sample"))
* CheckString("some fixed string") // вместо CheckString(FixedValue("some fixed string"))
* }}}
*
* ==Генерации markdown документа из набора примеров==
*
* {{{
* package ru.tinkoff.tcb.mockingbird.examples
*
* import ru.tinkoff.tcb.mockingbird.edsl.interpreter.MarkdownGenerator
*
* object CatsFactsMd {
* def main(args: Array[String]): Unit = {
* val mdg = MarkdownGenerator(host = "https://catfact.ninja")
* val set = new CatsFacts[MarkdownGenerator.HttpResponseR]()
* println(mdg.generate(set))
* }
* }
* }}}
*
* Здесь создается интерпретатор [[ru.tinkoff.tcb.mockingbird.edsl.interpreter.MarkdownGenerator MarkdownGenerator]] для
* генерации markdown документа из инстанса `ExampleSet`. Как параметр, конструктору передается хост со схемой который
* будет подставлен в качестве примера в документ.
*
* Как упоминалось ранее, тип ответа от HTTP сервера зависит от интерпретатора DSL, поэтому при создании `CatsFacts`
* параметром передается тип `MarkdownGenerator.HttpResponseR`.
*
* ==Генерация тестов из набора примеров==
* {{{
* package ru.tinkoff.tcb.mockingbird.examples
*
* import ru.tinkoff.tcb.mockingbird.edsl.interpreter.AsyncScalaTestSuite
*
* class CatsFactsSuite extends AsyncScalaTestSuite {
* override val host = "https://catfact.ninja"
* val set = new CatsFacts[HttpResponseR]()
* generateTests(set)
* }
* }}}
*
* Для генерации тестов нужно создать класс и унаследовать его от
* [[ru.tinkoff.tcb.mockingbird.edsl.interpreter.AsyncScalaTestSuite AsyncScalaTestSuite]]. После чего в переопределить
* значение `host` и в конструкторе вызвать метод `generateTests` передав в него набор примеров. В качестве дженерик
* параметра для типа HTTP ответа, в создаваемый инстанс набора примеров надо передать тип
* [[ru.tinkoff.tcb.mockingbird.edsl.interpreter.AsyncScalaTestSuite.HttpResponseR AsyncScalaTestSuite.HttpResponseR]]
*
* Пример запуска тестов:
* {{{
* [info] CatsFactsSuite:
* [info] - Получение случайного факта о котиках
* [info] + Отправить GET запрос
* [info] + Ответ содержит случайный факт полученный с сервера
* [info] Run completed in 563 milliseconds.
* [info] Total number of tests run: 1
* [info] Suites: completed 1, aborted 0
* [info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
* [info] All tests passed.
* }}}
*/
trait ExampleSet[HttpResponseR] {
private var examples_ : Vector[ExampleDescription] = Vector.empty

final def examples: Vector[ExampleDescription] = examples_
final private[edsl] def examples: Vector[ExampleDescription] = examples_

/**
* Заглавие набора примеров.
*/
def name: String

final def example(name: String)(body: Example[Any])(implicit pos: source.Position): Unit =
final protected def example(name: String)(body: Example[Any])(implicit pos: source.Position): Unit =
examples_ = examples_ :+ ExampleDescription(name, body, pos)

/**
* Выводит сообщение при помощи `info` при генерации тестов или добавляет текстовый блок при генерации Markdown.
* @param text
* текст сообщения
*/
final def describe(text: String)(implicit pos: source.Position): Example[Unit] =
liftF[Step, Unit](Describe(text, pos))

/**
* В тестах, выполняет HTTP запрос с указанными параметрами или добавляет в Markdown пример запроса, который можно
* исполнить командой `curl`.
*
* @param method
* используемый HTTP метод.
* @param path
* путь до ресурса без схемы и хоста.
* @param body
* тело запроса как текст.
* @param headers
* заголовки, который будут переданы вместе с запросом.
* @param query
* URL параметры запроса
* @return
* возвращает объект представляющий собой результат исполнения запроса, конкретный тип зависит от интерпретатора
* DSL. Использовать возвращаемое значение можно только передав в метод [[checkHttp]].
*/
final def sendHttp(
method: HttpMethod,
path: String,
Expand All @@ -29,6 +194,20 @@ trait ExampleSet[HttpResponseR] {
): Example[HttpResponseR] =
liftF[Step, HttpResponseR](SendHttp[HttpResponseR](HttpRequest(method, path, body, headers, query), pos))

/**
* В тестах, проверяет, что полученный HTTP ответ соответствует ожиданиям. При генерации Markdown вставляет ожидаемый
* ответ опираясь на указанные ожидания. Если никакие ожидания не указана, то ничего добавлено не будет.
*
* @param response
* результат исполнения [[sendHttp]], тип зависит от интерпретатора DSL.
* @param expects
* ожидания предъявляемые к результату HTTP запроса. Ожидания касаются кода ответа, тела запроса и заголовков
* полеченных от сервера.
* @return
* возвращает разобранный ответ от сервера. При генерации Markdown, так как реального ответа от сервера нет, то
* формирует ответ на основании переданных ожиданий от ответа. В Markdown добавляется информация только от том, для
* чего была указана проверка.
*/
final def checkHttp(response: HttpResponseR, expects: HttpResponseExpected)(implicit
pos: source.Position
): Example[HttpResponse] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,35 @@ import sttp.client3.*

import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet
import ru.tinkoff.tcb.mockingbird.edsl.model.*

import ru.tinkoff.tcb.mockingbird.edsl.model.Check.*
import ru.tinkoff.tcb.mockingbird.edsl.model.ValueMatcher.*

/**
* Базовый трейт для генерации набора тестов по набору примеров
* [[ru.tinkoff.tcb.mockingbird.edsl.ExampleSet ExampleSet]].
*
* Трейт наследуется от `AsyncFunSuiteLike` из фреймоврка [[https://www.scalatest.org/ ScalaTest]], поэтому внутри можно
* как дописать дополнительные тесты, так и использовать
* [[https://www.scalatest.org/user_guide/sharing_fixtures#beforeAndAfter BeforeAndAfter]] и/или
* [[https://www.scalatest.org/user_guide/sharing_fixtures#composingFixtures BeforeAndAfterEach]] для управления
* поднятием необходимого для исполнения тестов окружения, в том числе используя
* [[https://github.com/testcontainers/testcontainers-scala testcontainers-scala]].
*/
trait AsyncScalaTestSuite extends AsyncFunSuiteLike {

type HttpResponseR = sttp.client3.Response[String]

private val sttpbackend = HttpClientFutureBackend()

/**
* Хост с указанием схемы к которому будут выполнятся HTTP запросы.
*/
def host: String

def generateTests(es: ExampleSet[HttpResponseR]): Unit =
/**
* Сгенерировать тесты из набора примеров.
*/
protected def generateTests(es: ExampleSet[HttpResponseR]): Unit =
es.examples.foreach { desc =>
test(desc.name)(desc.steps.foldMap(testStepsBuilder).as(succeed))(desc.pos)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import pl.muninn.scalamdtag.tags.Markdown
import ru.tinkoff.tcb.mockingbird.edsl.ExampleSet
import ru.tinkoff.tcb.mockingbird.edsl.interpreter.buildRequest
import ru.tinkoff.tcb.mockingbird.edsl.model.*
import ru.tinkoff.tcb.mockingbird.edsl.model.Check.*
import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.Delete
import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.Get
import ru.tinkoff.tcb.mockingbird.edsl.model.HttpMethod.Post
Expand All @@ -34,14 +35,14 @@ object MarkdownGenerator {
implicit def valueMatcherShow[T: Show]: Show[ValueMatcher[T]] =
(vm: ValueMatcher[T]) =>
vm match {
case AnyValue(example) => example.show
case FixedValue(value) => value.show
case ValueMatcher.AnyValue(example) => example.show
case ValueMatcher.FixedValue(value) => value.show
}

implicit class ValueMatcherOps[T](private val vm: ValueMatcher[T]) extends AnyVal {
def value: T = vm match {
case AnyValue(example) => example
case FixedValue(value) => value
case ValueMatcher.AnyValue(example) => example
case ValueMatcher.FixedValue(value) => value
}
}

Expand All @@ -62,19 +63,31 @@ object MarkdownGenerator {
case CheckJsonObject(fields*) => Json.obj(fields.map { case (n, v) => n -> buildJson(v) }: _*)
case CheckJsonString(matcher) => Json.fromString(matcher.value)
}

}

}

class MarkdownGenerator(host: String) {
/**
* Интерпретатор DSL создающий markdown документ с описанием примера.
*
* @param host
* хост со схемой который будет подставлен в качестве примера для HTTP запросов.
*/
final class MarkdownGenerator(host: String) {
import MarkdownGenerator.HttpResponseR
import MarkdownGenerator.httpResponseR
import MarkdownGenerator.implicits.*
import cats.syntax.writer.*

type W[A] = Writer[Vector[Markdown], A]
private[interpreter] type W[A] = Writer[Vector[Markdown], A]

/**
* Сгенерировать markdown документ из переданного набора примеров.
*
* @param set
* набор примеров
* @return
* строка содержащая markdown документ.
*/
def generate(set: ExampleSet[HttpResponseR]): String = {
val tags = for {
_ <- Vector(h1(set.name)).tell
Expand All @@ -84,13 +97,13 @@ class MarkdownGenerator(host: String) {
markdown(tags.written).md
}

def generate(desc: ExampleDescription): W[Unit] =
private[interpreter] def generate(desc: ExampleDescription): W[Unit] =
for {
_ <- Vector[Markdown](h2(desc.name)).tell
_ <- desc.steps.foldMap(stepsPrinterW)
} yield ()

def stepsPrinterW: FunctionK[Step, W] = new (Step ~> W) {
private[interpreter] def stepsPrinterW: FunctionK[Step, W] = new (Step ~> W) {
def apply[A](fa: Step[A]): W[A] =
fa match {
case Describe(text, pos) => Vector(p(text)).tell
Expand Down Expand Up @@ -129,5 +142,4 @@ class MarkdownGenerator(host: String) {
)
}
}

}

0 comments on commit 02724de

Please sign in to comment.