Write Fewer Tests!

From Automation to Autogeneration

@davidkpiano → React Rally 2019

Let's fix this

And this

And this

And this

Requirements

Code

Tests

LOC

Time

"Write Tests Frequently" (WTF) technique

Red, Green, Refactor

Black and White

Unbreakable code

Human Nature

Lots of Unit Tests

Verify that you are a developer that knows how to code

What if tests can be generated

without being written?

What if tests can be regenerated

without being rewritten?

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 ✅

Loading

Success

Resolve

Initial state

States

Events

Transitions

Final State

Failed

REJECT

RETRY

State machines

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

Requirements

Abstract Model

Given [precondition]

precondition

postcondition

action

when [action]

then [postcondition]

Executable Tests

🔍

🔍

💥

1. Create a model

Kent C. Dots

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

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 { Machine } from 'xstate';

const feedbackMachine = Machine({
  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

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 { Machine } from 'xstate';
import { createModel } from '@xstate/test';
import { render, fireEvent, cleanup } from '@testing-library/react';

const feedbackMachine = Machine({
  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();
  });
});

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 → D → E

A → B → C → E

A → C → B → D → E 

Try that, Redux.

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

😐 Other Companies

  • xstate
  • @xstate/react
  • @xstate/graph
  • @xstate/test
  • @xstate/fsm
  • @xstate/viz
  • @xstate/vue
  • @xstate/patterns
  • @xstate/analytics
import { useMachine } from '@xstate/machine';
import { someMachine } from '../idk/somewhere';

export const App = () => {
  const [current, send] = useMachine(someMachine);
  
  return (/* ... */);
}
import { useReducer } from 'react';
import { someReducer, initial } from '../idk/somewhere';

export const App = () => {
  const [state, dispatch] = useReducer(someReducer, initial);
  
  return (/* ... */);
}

Coming soon

Released

Make your code

model-driven

generate

tests

docs

prototypes

do more.

Thank you React Rally!

@davidkpiano → React Rally 2019

Write Fewer Tests!

By David Khourshid

Write Fewer Tests!

React Rally 2019

  • 17,742