Andrey Mikhaylov (lolmaus)
frotnend developer at Perforce
https://lolma.us
https://github.com/lolmaus
https://perforce.com/products/helix-teamhub
from developer's perspective
Plan
- Not gonna tell you why automated tests are important
- Acceptance vs unit tests
- BDD
- Our current test setup
- What Cucumber does
- Why we should adopt Cucumber
- This talk is frontend-focused
Hierarchy of tests
Acceptance
Integration
Unit
$$$
$
Correct hierarchy of tests
Unit
Integration
Acceptance
Controller.extend({
spaceHorn: service(),
society: service(),
caramelize () {
const confusion = this.spaceHorn.honk()
return this.society.riot(confusion)
}
})
Is a unit test needed here?
Test-driven development
Test
Code
Refactor
User story
Behavior-driven development
Page
Component
Model
Service
Network
adapter
Start by writing a full feature spec in form of user stories
Our typical acceptance test
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`
)
})
})
Applying
the page object technique
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')
}
})
})
Extracting test logic into reusable functions
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)
})
Extracting test logic into reusable functions
Reuse the test logic
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})
})
DRY
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})
})
})
Enter Cucumber
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)
})
Cucumber feature
with a test matrix
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 |
-----------------------------------------------------
{
"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`)
})
}
Defining test logic
1. Complexity
Current setup
With Cucumber
- Mirage API layer
- Mirage factories and models
- Mirage scenarios (reusable seeding logic)
- Page objects
- Page object components
- Test suite scaffolding
- Assertion library
- Technical implementation of tests
- Test matrix scaffolding (sometimes)
- Mirage API layer
- Mirage factories and models
- Mirage scenarios (reusable seeding logic)
- Page objects
- Page object components
- Test suite scaffolding
- Assertion library
- Technical implementation of user story steps
- Cucumber features, composed of human-readable steps
2. Separation of concerns
Current setup
With Cucumber
- Three different concerns:
- test case descriptions,
- their technical implementation,
- glue code
- ... are all cluttered together.
-
Test cases are separated from technical implementation.
-
Technical implementation is broken into small, atomic steps.
-
Steps are composed naturally, so glue code almost doesn’t exist.
3. Code Reusability
Current setup
With Cucumber
We still keep writing duplicate test code every month
Steps are reusable.
4. Code Readability
Current setup
With Cucumber
-
Tests are mostly “write only code”.
- It takes minutes to red into each test in order to validate it.
- Technical implementation is visual noise.
-
Test cases are readable.
-
Easy to read through & validate.
5. Code Maitainability
Current setup
With Cucumber
-
Tests are effectively unmaintained.
- We don't have test maintenance on schedule.
- Maintaining long sheets of code is hard.
-
Little maintenance needed.
-
Maintenance is easy.
6. Ease of validation
Current setup
With Cucumber
-
Difficult to validate.
- Tests become dead code over time.
- Sit and pray.
-
Easy to validate.
-
Pure English.
7. Workflow integration
Current setup
With Cucumber
-
Only developers can read tests.
- Feature creation workflow is interrupted.
-
Testers, designers, product owners can participate in writing features.
-
...and in validating features.
-
Feature creation workflow is continuous.
8. Discipline
Current setup
With Cucumber
- No intrinsic structure that could impose discipline.
-
Strict methodology and code style greatly contributes to code quality.
9. Coverage
Current setup
With Cucumber
- We only cover very basic cases, even when attempting BDD.
- BDD isn’t enforced by anything but dev’s conscience.
-
Cucumber methodology imposes maximum coverage.
-
Developers are less likely to cut corners.
10. Code unification
Current setup
With Cucumber
- Code style still varies greatly across our test codebase.
- No reference implementation.
-
Cucumber imposes a lot of structure.
-
It's harder to stray.
10. Speed of writing tests
Current setup
With Cucumber
- Initially faster.
- Hidden costs.
-
Generally slower.
-
Pays off greatly later.
- Cucumber doesn't add much to the complexity of our test codebase.
- In our attempt to make tests better we have essentially reinvented Cucumber in an amateur, imperfect way.
- Using a well-thought out methodology is obviously better than its imperfect custom equivalent.
- The debate boils down to whether we should go all the way with BDD or not.
- I strongly believe that we should, since it is provides numerous benefits.
- It does not prevent us from doing it the old way when we feel like it. We should simply agree to avoid the old way as much as possible.
TL/DL
That's all! Thx everyone!
Cucumber
By Andrey Mikhaylov (lolmaus)
Cucumber
- 714