Frotnend-разработчик в Kaliber5
https://lolma.us
https://github.com/lolmaus
https://kaliber5.de
https://bit.ly/bdd-cucumber
Acceptance
Integration
Unit
Acceptance
Integration
Unit
$$$
$
Acceptance
Integration
Unit
$$$
$
Unit
Integration
Acceptance
test("apples should be sorted by id", function (assert) {
const user = server.create("user")
server.createList("apples", 20, {ownerId: user.id})
authenticateSession(user)
visit("/apples")
const apples = findAll(".apples .apple")
apples.forEach((currentApple, i) => {
if (i === 0) return
const currentValue = currentApple.querySelector(".apple-id").textContent
const previousApple = apples[i - 1]
const previousValue = previousApple.querySelector(".apple-id").textContent
assert.ok(
currentValue > previousValue,
`Apple ${i} id should be greater than apple ${i -1} id`
)
})
})
test("apples should be sorted by id", function (assert) {
const user = server.create("user")
server.createList("apples", 20, {ownerId: user.id})
authenticateSession(user)
page.visit()
page.apples.forEach((currentApple, i) => {
if (i === 0) return
const previousApple = page.apples[i - 1]
assert.ok(
currentApple.id > previousApple.id,
`Apple ${i} id should be greater than apple ${i -1} id`
)
})
})
export default create({
visitable: "/apples",
apples: collection({
scope: ".apples",
itemScope: ".apple",
item: {
id: text('.apple-id')
}
})
})
test("apples should be sorted by id", function (assert) {
const user = server.create("user")
server.createList("apples", 20, {ownerId: user.id})
authenticateSession(user)
page.visit()
page.apples.forEach((currentApple, i) => {
if (i === 0) return
const previousApple = page.apples[i - 1]
assert.ok(
currentApple.id > previousApple.id,
`Apple ${i} id should be greater than apple ${i -1} id`
)
})
})
createUser()
createApples(amount)
login()
visit()
applesShouldBeSortedById(assert)
let user, apples
function createUser () {
user = server.create("user")
}
function createApples () {
apples = server.createList("apples", 20, {ownerId: user.id})
}
function login () {
authenticateSession(user)
}
function visit () {
page.visit()
}
function applesShouldBeSortedById (assert) {
page.apples.forEach((currentApple, i) => {
if (i === 0) return
const previousApple = page.apples[i - 1]
assert.ok(
currentApple.id > previousApple.id,
`Apple ${i} id should be greater than apple ${i -1} id`
)
})
}
test("apples should be sorted by id", function (assert) {
createUser()
createApples()
login()
visit()
applesShouldBeSortedById(assert)
})
test("apples should be sorted by id (asc) by default", function (assert) {
createUser()
createApples()
login()
visit()
applesShouldBeSortedBy({column: "id", order: "asc", assert})
})
test("apples should be sorted by id (desc) when URL contains order=desc", function (assert) {
createUser()
createApples()
login()
visit({order: "desc"})
applesShouldBeSortedBy({column: "id", order: "desc", assert})
})
test("apples should be sorted by title (asc) when URL contains column=title", function (assert) {
createUser()
createApples()
login()
visit({column: "title"})
applesShouldBeSortedBy({column: "title", order: "asc", assert})
})
const cases = [
{queryParams: { }, column: "id", order: "asc" },
{queryParams: { order: "asc" }, column: "id", order: "asc" },
{queryParams: { order: "desc"}, column: "id", order: "desc"},
{queryParams: {column: "title" }, column: "title", order: "asc" },
{queryParams: {column: "title", order: "asc" }, column: "title", order: "asc" },
{queryParams: {column: "title", order: "desc"}, column: "title", order: "desc"},
{queryParams: {column: "color" }, column: "color", order: "asc" },
{queryParams: {column: "color", order: "asc" }, column: "color", order: "asc" },
{queryParams: {column: "color", order: "desc"}, column: "color", order: "desc"},
]
cases.forEach(({queryParams, column, order}) => {
const params = JSON.stringify(queryParams)
test(`apples should be sorted by ${column} (${order}) when URL conains query params ${params}`, function (assert) {
createUser()
createApples()
login()
visit(queryParams)
applesShouldBeSortedBy({column, order, assert})
})
})
Scenario: Apples should be sorted by id
Given a user
And 20 apples
When user is authenticated
And user visits the apples page
Then apples should be sorted by id
test("apples should be sorted by id", function (assert) {
createUser()
createApples()
login()
visit()
applesShouldBeSortedById(assert)
})
sorting-apples.feature
синтаксис "Gherkin"
Feature: Sorting apples
Scenario: Apples should be sorted by [Column] ([Order]) when URL contains QPs [QPs]
Given a user
And 20 apples
When user is authenticated
And user visits the apples page using QPs [QPs]
Then apples should be sorted by [Column] column in [Order] order
Where:
-----------------------------------------------------
| QPs | Column | Order |
| {} | id | asc |
| {order: "asc"} | id | asc |
| {order: "desc"} | id | asc |
| {column: "title"} | title | asc |
| {column: "title", order: "asc"} | title | asc |
| {column: "title", order: "desc"} | title | desc |
| {column: "color"} | color | asc |
| {column: "color", order: "asc"} | color | asc |
| {column: "color", order: "desc"} | color | desc |
-----------------------------------------------------
const cases = [
{queryParams: { }, column: "id", order: "asc" },
{queryParams: { order: "asc" }, column: "id", order: "asc" },
{queryParams: { order: "desc"}, column: "id", order: "desc"},
{queryParams: {column: "title" }, column: "title", order: "asc" },
{queryParams: {column: "title", order: "asc" }, column: "title", order: "asc" },
{queryParams: {column: "title", order: "desc"}, column: "title", order: "desc"},
{queryParams: {column: "color" }, column: "color", order: "asc" },
{queryParams: {column: "color", order: "asc" }, column: "color", order: "asc" },
{queryParams: {column: "color", order: "desc"}, column: "color", order: "desc"},
]
cases.forEach(({queryParams, column, order}) => {
const params = JSON.stringify(queryParams)
test(`apples should be sorted by ${column} (${order}) when URL conains query params ${params}`, function (assert) {
createUser()
createApples()
login()
visit(queryParams)
applesShouldBeSortedBy({column, order, assert})
})
})
{
"given a user" () {
this.ctx.user = server.create("user")
},
"given $count apples" (countStr) {
const count = parseInt(countStr, 10)
const onwerId = this.ctx.user.id
this.ctx.apples = server.createList("apple", count, {ownerId})
},
"when user is authenticated" () {
authenticateSession(this.ctx.user)
},
"when user visits the apples page using QPs $qps" (qpsStr) {
const qps = JSON.parse(qpsStr)
page.visit(qps)
},
"then apples should be sorted by $column column in $order order" (column, order, assert) {
page.apples.forEach((currentApple, i) => {
if (i === 0) return
const currentValue = currentApple[column].text
const previousApple = page.apples[i - 1]
const previousValue = previousApple[column].text
const result =
order === "asc" ? currentValue >= previousValue :
order === "desc" ? currentValue <= previousValue :
null
assert.ok(result, `Apple ${i} id should be ${order} than apple ${i - 1} id`)
})
}
}
let user, apples
function createUser () {
user = server.create("user")
}
function createApples (count) {
apples = server.createList("apples", count , {ownerId: user.id})
}
function login () {
authenticateSession(user)
}
function visit (queryParams = {}) {
page.visit(qps)
}
function applesShouldBeSortedBy (column, order , assert) {
page.apples.forEach((currentApple, i) => {
if (i === 0) return
const currentValue = currentApple[column].text
const previousApple = page.apples[i - 1]
const previousValue = previousApple[column].text
const result =
order === "asc" ? currentValue >= previousValue :
order === "desc" ? currentValue <= previousValue :
null
assert.ok(result, `Apple ${i} id should be ${order} than apple ${i - 1} id`)
})
}
1. Сравниваем сложность
Классические тесты
С Cucumber
2. Separation of concerns
(разделение зон ответственности)
Тест-кейсы и их имплементация отделены друг от друга.
Имплементация разбита на маленькие, атомарные шаги.
Композиция шагов осуществляется естественным образом, прослоечный код отсутствует в принципе.
Классические тесты
С Cucumber
3. Переиспользование кода (DRY)
Пишем повторяющийся код каждый день.
Переиспользуем
названия шагов.
Классические тесты
С Cucumber
4. Читаемость кода
Классические тесты
С Cucumber
Тест-кейсы написаны
на английском языке.
Легко читать, легко пробегать глазами, легко валидировать.
Техническая имплементация
не засоряет эфир
5. Поддерживаемость кода
Классические тесты
С Cucumber
Поддержка особо не требуется.
Поддерживать легко.
6. Простота валидации
Current setup
With Cucumber
Легко валидировать.
7. Интеграция в рабочий процесс
Классические тесты
С Cucumber
Тестеры, дизайнеры, менеджеры, представители заказчика могут писать тест-кейсы.
...и валидировать их.
Процесс разработки непрерывный.
8. Дисциплина
Классические тесты
С Cucumber
Строгая методология
и жесткая структура обеспечивают высокое качество кода и продукта.
9. Покрытие кода
Классические тесты
С Cucumber
Максимальное покрытие благодаря легкости размножения тестов.
Степень покрытия обеспечивается на этапе планирования.
Сложно "срезать углы".
10. Унификация кода
Классические тесты
С Cucumber
Структура жестко определена и везде одинакова.
По-прежнему можно всё запутать до невозможности,
об этом далее.
11. Скорость написания тестов
Классические тесты
С Cucumber
Писать имплементацию новых шагов -- достаточно долго.
Переиспользовать имеющиеся шаги -- очень быстро.
TL/DR
Проблемы Cucumber
Начинается всё невинно.
Проверяем посты в блоге:
Given there are 2 Posts in the database
When I visit the blog
Then I should see a list of posts
And there should be two posts in the list
Добавляем проверку заголовков
Given there are 2 Posts in the database
When I visit the blog
Then I should see a list of posts
And there should be two posts in the list
And post 1 title should be "111"
And post 2 title should be "222"
Добавляем проверку заголовков
Given there are 2 Posts in the database
When I visit the blog
Then I should see a list of posts
And there should be two posts in the list
And post 1 title should be "111"
And post 2 title should be "222"
Добавляем проверку комментариев
Given there are 2 Posts in the database
When I visit the blog
Then I should see a list of posts
And there should be two posts in the list
And post 1 title should be "111"
And post 2 title should be "222"
And post 1 should have 2 comments
And post 2 should have 0 comments
And comment 1 of post 2 should have 2 replies
Добавляем Reaction Emoji
Given there are 2 Posts in the database
And comment 4 has reaction emoji "thumbs-up"
When I visit the blog
Then I should see a list of posts
And there should be two posts in the list
And post 1 title should be "111"
And post 2 title should be "222"
And post 1 should have 2 comments
And post 2 should have 0 comments
And comment 1 of post 2 should have 2 replies
And reply 1 of comment 2 to post 1 should have reaction emoji "thumbs-up"
Добавляем закрепленный пост
Given there is a pinned post in the database
And there are 2 Posts in the database
And comment 4 has reaction emoji "thumbs-up"
When I visit the blog
Then I should see a list of posts
And there should be three posts in the list
And post 1 title should be "111"
And post 2 title should be "222"
And post 1 should have 2 comments
And post 2 should have 0 comments
And comment 1 of post 2 should have 2 replies
And reply 1 of comment 2 to post 1 should have reaction emoji "thumbs-up"
Решение: Выносим "правду" в feature-файлы. Наполнение БД:
Given there are records of type User in the database:
-----------------------------
| id | name | admin |
| "alice" | "Alice" | true |
| "bob" | "Alice" | true |
-----------------------------
Given there are records of type Post in the database:
-----------------------------------------
| id | title | body | pinned | author |
| 1 | "Hi!" | "Pin" | true | @alice |
| 2 | "Aaa" | "A" | | @bob |
| 3 | "Bbb" | "B" | | @bob |
-----------------------------------------
And there are records of type Comment in the database:
--------------------------------------------
| id | body | author | post | parent |
| "1" | "Ololo" | @alice | @2 | |
| "2" | "Trololo" | @bob | | @1 |
--------------------------------------------
Выносим "правду" в feature-файлы.
Шаг посещения страницы:
# Before:
When I visit the blog
Then I should be at the blog
# After:
When I visit /blog
Then I should be at /blog
Выносим "правду" в feature-файлы.
Взаимодействие с DOM:
<ul data-test-main-menu>
<li data-test-item="home">...</li>
<li data-test-item="products">...</li>
<li data-test-item="pricing">...</li>
</ul>
Паттерн "Test Selectors":
Then I should see a [data-test-main-menu]
And there should be 3 [data-test-main-menu] [data-test-menu-item]
When I click [data-test-main-menu] [data-test-menu-item]:nth-child(1)
Применяем в шагах:
Используем labels
Main-Menu [data-test-main-menu]
1st Main-Menu [data-test-main-menu]:eq(0)
the first Main-Menu [data-test-main-menu]:eq(0)
first Item in the Main-Menu
[data-test-main-menu] [data-test-item]:eq(0)
Item(Pricing) in the 2nd Main-Menu
[data-test-main-menu]:eq(1) [data-test-item="Pricing"]
Выносим "правду" в feature-файлы.
Взаимодействие с DOM:
labelMap.set('Modal-Dialog', '.modal-dialog');
labelMap.set('Primary-Button', '.btn-primary');
Алиасы для лэйблов:
Автоматическое преобразование алиасов:
the Primary-Button in the Comment-Edit-Form of the Modal-Dialog
.modal-dialog [data-test-comment-edit-form] .primary-button
Пользователю доступно не так много типов действий:
When I click the Save-Button in the Post-Edit-Form
When I type "Hello!" into the Title-Field of the Post-Edit-Form
When I select the 2nd item in the dropdown Sex of the User-Form
When I select the item "Prefer not to tell" in the dropdown Sex of the User-Form
When I move the mouse pointer into the Thumbnail of the 2nd Post
...
Then I should see a Post
Then I should NOT see Posts
Then I should see 2 Posts
Then the Title of the 1st Post should be "Hello!"
Then the first Post should have HTML class "pinned"
Then the first Post should HTML attribute "role" with value "article"
...
Объединение атомарных шагов в один составной
When I click on the Trigger of the Tags-Dropdown
Then I should see a Dropdown of the Tags-Dropdown
And I should see a Search field in the Dropdown of the Tags-Dropdown
And I should NOT see a Results-List in the Dropdown of the Tags-Dropdown
When I type "bug" into the Search field of the Dropdown of the Tags-Dropdown
Then I should see a Results-List in the Dropdown of the Tags-Dropdown
And I should see 1 Item in the Results-List in the Dropdown of the Tags-Dropdown
And the Item in the Results-List in the Dropdown of the Tags-Dropdown should have text "bug"
When I click the Item in the Results-List in the Dropdown of the Tags-Dropdown
Then I should NOT see a Dropdown of the Tags-Dropdown
And the Trigger of the Tags-Dropdown should have text "bug"
When I select "bug" in the Tags-Dropdown using look-ahead search
Проблема: затруднена композиция шагов
// CucumberJS
Given('a variable set to {int}', function(number) {
this.setTo(number)
})
When('I increment the variable by {int}', function(number) {
this.incrementBy(number)
})
Then('the variable should contain {int}', function(number) {
expect(this.variable).to.eql(number)
})
// Yadda
export default library
.given('a variable set to $int', function(number) {
this.setTo(number)
})
.when('I increment the variable by $int', function(number) {
this.incrementBy(number)
})
.then('the variable should contain $int', function(number) {
expect(this.variable).to.eql(number)
})
// My opinionated approach
export default {
'Given a variable set to {int}' (number) {
this.setTo(number)
},
'When I increment the variable by {int}', function(number) {
this.incrementBy(number)
},
'Then the variable should contain {int}', function(number) {
expect(this.variable).to.eql(number)
}
}
Решение: свой способ записи шагов
Проблема: затруднена отладка
Решение: самостоятельно генерируем внятные сообщения об ошибках
Всем спасибо! ^_^
https://cucumber.io
https://docs.cucumber.io/installation/javascript
https://github.com/acuminous/yadda
https://github.com/kaliber5/ember-cli-yadda-opinionated
Алан Купер "Психбольница в руках пациентов"
Alan Cooper "The Inmates Are Running the Asylum"
Секретный слайд