Write Fewer Tests!
Model-Based Testing in React
Let's fix this
And this
And this
And this
Requirements
Code
Tests
Lines of Code
Time
Lots of Unit Tests
Verify that you are a developer that knows how to code
What if tests can be
generated
What if tests can be
generated
without being
written?
What if tests can be
re
without being
written?
generated
What if tests can be
re
without being
written?
generated
re
Model Based Testing
Automatic Test Generation
From 3 and 5 gallon jugs, how can you
Measure 4 gallons exactly?
❓❓❓
❓❓❓ three
❓❓❓❓❓ five
💧💧💧 three
❓❓❓❓❓ five
❓❓❓ three
💧💧💧❓❓ five
💧💧💧 three
💧💧💧❓❓five
💧❓❓ three
💧💧💧💧💧 five
💧❓❓ three
❓❓❓❓❓ five
❓❓❓ three
💧❓❓❓❓ five
💧💧💧 three
💧❓❓❓❓ five
❓❓❓ three
💧💧💧💧❓ five ✅
Finite state machines
Idle
Every app starts in
some initial state
Idle
There can be a finite
number of states
Loading
Success
Failure
Idle
State can transition
to each other
Success
Failure
Loading
Idle
Events cause states
to transition
Success
Failure
Loading
LOAD
RESOLVE
REJECT
Idle
Loading
💥
0, 0
0, 5
3, 0
3, 5
3, 2
0, 2
2, 0
2, 5
3, 4
3, 3
1, 0
1, 5
0, 1
3, 1
0, 4
0, 3
0, 0
0, 5
3, 2
0, 2
2, 0
2, 5
3, 4
Requirements
Given [precondition]
when [action]
then [postcondition]
Requirements
Abstract Model
Given [precondition]
precondition
postcondition
action
when [action]
then [postcondition]
Requirements
Abstract Model
Given [precondition]
precondition
postcondition
action
when [action]
then [postcondition]
Executable Tests
🔍
🔍
💥
1. Create a model
1. Create a model
2. Generate abstract tests
1. Create a model
2. Generate abstract tests
3. Make them real
🖱
⌨️
⏱
💥
Precondition
Postcondition
1. Create a model
2. Generate abstract tests
3. Make them real
4. Execute the tests
1. Create a model
2. Generate abstract tests
3. Make them real
4. Execute the tests
Model regenerates tests
5. Profit (seriously)
How was your experience?
𝘅
Good
Bad
Tell me why?
𝘅
Submit
Thanks for your feedback.
𝘅
Close
Ain't nothin' but a heartache
Loading...
(concurrent mode would have fixed this)
🐙
import { render, fireEvent, cleanup } from '@testing-library/react';
// ...
describe('feedback app', () => {
afterEach(cleanup);
it('should show the thanks screen when "Good" is clicked', () => {
const { getByTestId } = render(<Feedback />);
// The question screen should be visible at first
assert.ok(getByTestId('question-screen'));
// Click the "Good" button
fireEvent.click(getByTestId('good-button'));
// Now the thanks screen should be visible
assert.ok(getByTestId('thanks-screen'));
});
it('should show the form screen when "Bad" is clicked', () => {
const { getByTestId } = render(<Feedback />);
// The question screen should be visible at first
assert.ok(getByTestId('question-screen'));
// Click the "Bad" button
fireEvent.click(getByTestId('bad-button'));
// Now the form screen should be visible
assert.ok(getByTestId('form-screen'));
});
});
import { createMachine } from 'xstate';
const feedbackMachine = createMachine({
id: 'feedback',
initial: 'question',
states: {
question: {
on: {
CLICK_GOOD: 'thanks',
CLICK_BAD: 'form',
CLOSE: 'closed'
}
},
form: {
on: {
SUBMIT: 'thanks',
CLOSE: 'closed'
}
},
thanks: {
on: {
CLOSE: 'closed'
}
},
closed: {
type: 'final'
}
}
});
question
thanks
form
closed
CLICK_GOOD
CLICK_BAD
SUBMIT
CLOSE
CLOSE
CLOSE
A
B
C
D
E
Shortest paths
A
B
C
D
E
Shortest paths
A → C → E
A
B
C
D
E
Shortest paths
A → B → D → E
Weighted: Dijkstra's algorithm
A
B
C
D
E
Simple paths
A → C → E
A
B
C
D
E
Simple paths
A → C → E
A → B → D → E
A
B
C
D
E
Simple paths
A → C → E
A → B → D → E
A → B → C → E
A
B
C
D
E
Simple paths
A → C → E
A → B → D → E
A → B → C → E
A → C → B → D → E
import { getSimplePaths } from '@xstate/graph';
import { feedbackMachine } from '../path/to/feedbackMachine';
const shortestPaths = getShortestPaths(feedbackMachine);
question
question
form
question → CLICK_BAD → form
thanks
question → CLICK_BAD → form → SUBMIT → thanks
question → CLICK_GOOD → thanks
closed
question → CLOSE → closed
question → CLICK_BAD → form → CLOSE → closed
question → CLICK_GOOD → thanks → CLOSE → closed
question → CLICK_BAD → form → SUBMIT → thanks → CLOSE → closed
import { createMachine } from 'xstate';
import { createModel } from '@xstate/test';
import { render, fireEvent, cleanup } from '@testing-library/react';
const feedbackMachine = createMachine({
id: 'feedback',
initial: 'question',
states: {
question: {
on: {
CLICK_GOOD: 'thanks',
CLICK_BAD: 'form',
CLOSE: 'closed'
},
meta: {
test: ({ getByTestId }) => {
assert.ok(getByTestId('question-screen'));
}
}
},
form: {
on: {
SUBMIT: [
{
target: 'thanks',
cond: (_, e) => e.value.length
}
],
CLOSE: 'closed'
},
meta: {
test: ({ getByTestId }) => {
assert.ok(getByTestId('form-screen'));
}
}
},
thanks: {
on: {
CLOSE: 'closed'
},
meta: {
test: ({ getByTestId }) => {
assert.ok(getByTestId('thanks-screen'));
}
}
},
closed: {
type: 'final',
meta: {
test: ({ queryByTestId }) => {
assert.isNull(queryByTestId('thanks-screen'));
}
}
}
}
});
// ...
import { Machine } from 'xstate';
import { createModel } from '@xstate/test';
const feedbackMachine = Machine(/* ... */);
const feedbackModel = createModel(feedbackMachine, {
events: {
CLICK_GOOD: ({ getByText }) => {
fireEvent.click(getByText('Good'));
},
CLICK_BAD: ({ getByText }) => {
fireEvent.click(getByText('Bad'));
},
CLOSE: ({ getByTestId }) => {
fireEvent.click(getByTestId('close-button'));
},
ESC: ({ baseElement }) => {
fireEvent.keyDown(baseElement, { key: 'Escape' });
},
SUBMIT: {
exec: async ({ getByTestId }, event) => {
fireEvent.change(getByTestId('response-input'), {
target: { value: event.value }
});
fireEvent.click(getByTestId('submit-button'));
},
cases: [
{ type: 'SUBMIT', value: 'something' },
{ type: 'SUBMIT', value: '' }
]
}
}
});
// ...
import { Machine } from 'xstate';
import { createModel } from '@xstate/test';
const feedbackMachine = Machine(/* ... */);
const feedbackModel = createModel(/* ... */);
describe('feedback app', () => {
const testPlans = feedbackModel.getShortestPathPlans();
testPlans.forEach(plan => {
describe(plan.description, () => {
afterEach(cleanup);
plan.paths.forEach(path => {
it(path.description, () => {
const rendered = render(<Feedback />);
return path.test(rendered);
});
});
});
});
});
// ...
import { Machine } from 'xstate';
import { createModel } from '@xstate/test';
const feedbackMachine = Machine(/* ... */);
const feedbackModel = createModel(/* ... */);
describe('feedback app', () => {
const testPlans = feedbackModel.getShortestPathPlans();
testPlans.forEach(/* ... */);
it('should have full coverage', () => {
return testModel.testCoverage();
});
});
Let's take this further.
Let's take this further.
Demo 😅
Time-travel debugging
IN THE FUTURE.
So what does this have to do with React?
Absolutely nothing.
TEST
npm install @xstate/test
GraphWalker
HARDER
BETTER
FASTER
STRONGER
Learning curve
Limited tooling
No guarantees
Model might be wrong
(but that's your fault)
Requirements integrity
Test generation
Flexibility
Reduce costs/time
Efficiency
Edge case discovery
Maintenance
Implementation agnostic
xstate
@xstate/react
@xstate/graph
@xstate/vue
@xstate/test
@xstate/fsm
@xstate/analytics
@xstate/viz
✅
✅
✅
🔜
✅
🔜
✅
npm install xstate
@xstate/scxml
🔜
@xstate/immer
🔜
✅
🙂 Other Companies
Make your code
model-driven
generate
tests
docs
prototypes
do more.
Thank you Sydney! 🇦🇺
Write fewer tests! Model-based testing in React
By David Khourshid
Write fewer tests! Model-based testing in React
React Conf AU 2020
- 8,540