├── .gitignore
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── public
├── index.html
└── manifest.json
├── src
├── App.tsx
├── L1
│ ├── Order.test.tsx
│ ├── Order.tsx
│ └── Order_Final.test.tsx
├── L2
│ ├── Order.test.tsx
│ ├── Order.tsx
│ └── Order_Final.test.tsx
├── L3
│ ├── Order.test.tsx
│ ├── Order.tsx
│ └── Order_Final.test.tsx
├── L4
│ ├── Order.test.tsx
│ ├── Order.tsx
│ └── Order_Final.test.tsx
├── L5
│ ├── OrderMachine.test.tsx
│ ├── OrderMachine.tsx
│ └── OrderMachine_Final.test.tsx
├── L6
│ ├── Order.test.tsx
│ └── Order.tsx
├── Order.css
├── index.css
├── index.js
├── react-app-env.d.ts
├── serviceWorker.js
└── setupTests.js
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 John Bales
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Model Based Testing Tutorial
2 |
3 | ### Lessons
4 |
5 | 1. Basic MBT UI Tests
6 | 2. Using Test Machine Context to repeat paths
7 | 3. Using Test Machine Context to explore non-shortest paths
8 | 4. Testing Asynchronous Services
9 | 5. Model Based Testing the State Machine
10 | 6. Bonus - Freestyle
11 |
12 | ### First Steps
13 |
14 | Install dependencies `npm i`
15 |
16 | ### Completing the Tutorial
17 |
18 | The tutorial is laid out in individual lessons.
19 |
20 | The recommend way to complete the tutorial is to
21 |
22 | - Update `App.tsx` to import `Order` from the current lesson folder
23 | - Run the program using `npm start` to visualize the UI
24 | - Familiarize yourself with the lesson's Order Component
25 | - Try to complete the instructions in `Order.test.tsx` and successfully cover all tests
26 | - Compare your results with the answers at `Order_Final.test.tsx`
27 | - Repeat with the next lesson
28 |
29 | Note that L5 is just the machine, no UI.
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mbt",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@material-ui/core": "^4.11.0",
7 | "@testing-library/jest-dom": "^4.2.4",
8 | "@testing-library/user-event": "^7.2.1",
9 | "@types/jest": "^26.0.7",
10 | "@types/node": "^14.0.26",
11 | "@types/react": "^16.9.43",
12 | "@types/react-dom": "^16.9.8",
13 | "@xstate/react": "^0.8.1",
14 | "@xstate/test": "^0.4.0",
15 | "prettier": "^2.0.5",
16 | "react": "^16.13.1",
17 | "react-dom": "^16.13.1",
18 | "react-scripts": "3.4.1",
19 | "typescript": "^3.9.7",
20 | "xstate": "^4.11.0"
21 | },
22 | "scripts": {
23 | "start": "react-scripts start",
24 | "build": "react-scripts build",
25 | "test": "react-scripts test",
26 | "eject": "react-scripts eject"
27 | },
28 | "eslintConfig": {
29 | "extends": "react-app"
30 | },
31 | "browserslist": {
32 | "production": [
33 | ">0.2%",
34 | "not dead",
35 | "not op_mini all"
36 | ],
37 | "development": [
38 | "last 1 chrome version",
39 | "last 1 firefox version",
40 | "last 1 safari version"
41 | ]
42 | },
43 | "devDependencies": {
44 | "@testing-library/react": "^9.5.0"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "start_url": ".",
5 | "display": "standalone",
6 | "theme_color": "#000000",
7 | "background_color": "#ffffff"
8 | }
9 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Order from "./L1/Order";
3 |
4 | function App() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
12 | export default App;
13 |
--------------------------------------------------------------------------------
/src/L1/Order.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine } from "xstate";
3 | import Order, { getOrderMachineDefinition } from "./Order";
4 | import { render, RenderResult, fireEvent, wait } from "@testing-library/react";
5 | import React from "react";
6 |
7 | ////////////////////////////////////////////////////////////////////////////////
8 | // Basic MBT UI Tests
9 | ////////////////////////////////////////////////////////////////////////////////
10 |
11 | // This is the basic setup for model based testing
12 | // Note the relation to the MBT diagram
13 | // render() is passed into path.test, thus all Event and Assert
14 | // functions can deconstruct from render
15 | // Fill in the comments
16 | // 1. Deconstruct getByText
17 | // 2. Act and Assert as you would in any ordinary test
18 |
19 | const getEventConfigs = () => {
20 | const eventConfigs = {
21 | // What action in the UI would trigger this event?
22 | ADD_TO_CART: {
23 | exec: async () => {
24 | // Action
25 | },
26 | },
27 | PLACE_ORDER: {
28 | exec: async () => {
29 | // Action
30 | },
31 | },
32 | };
33 |
34 | return eventConfigs;
35 | };
36 |
37 | const shoppingTest = {
38 | // What do I assert to verify the UI is in the Shopping state?
39 | test: async () => {
40 | // Assert
41 | },
42 | };
43 | const cartTest = {
44 | test: async () => {
45 | // Assert
46 | },
47 | };
48 | const orderedTest = {
49 | test: async () => {
50 | // Assert
51 | },
52 | };
53 |
54 | describe("Order", () => {
55 | describe("matches all paths", () => {
56 | const testMachineDefinition = getOrderMachineDefinition();
57 |
58 | (testMachineDefinition.states.shopping as any).meta = shoppingTest;
59 | (testMachineDefinition.states.cart as any).meta = cartTest;
60 | (testMachineDefinition.states.ordered as any).meta = orderedTest;
61 |
62 | const testMachine = Machine(testMachineDefinition);
63 |
64 | const testModel = createModel(testMachine).withEvents(
65 | getEventConfigs() as any
66 | );
67 |
68 | const testPlans = testModel.getShortestPathPlans();
69 |
70 | testPlans.forEach((plan) => {
71 | describe(plan.description, () => {
72 | plan.paths.forEach((path) => {
73 | it(path.description, async () => {
74 | await path.test(render());
75 | });
76 | });
77 | });
78 | });
79 |
80 | it("should have full coverage", () => {
81 | return testModel.testCoverage();
82 | });
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/src/L1/Order.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "../Order.css";
3 | import Button from "@material-ui/core/Button";
4 | import { Machine } from "xstate";
5 | import { useMachine } from "@xstate/react";
6 |
7 | const getOrderMachineDefinition = () => ({
8 | id: "order",
9 | initial: "shopping",
10 | states: {
11 | shopping: { on: { ADD_TO_CART: "cart" } },
12 | cart: { on: { PLACE_ORDER: "ordered" } },
13 | ordered: {},
14 | },
15 | });
16 |
17 | const Order: React.FC = () => {
18 | const [orderMachineState, send] = useMachine(
19 | Machine(getOrderMachineDefinition())
20 | );
21 |
22 | return (
23 |
24 |
{orderMachineState.value}
25 |
26 |
33 |
40 |
41 |
42 | );
43 | };
44 |
45 | export default Order;
46 | export { getOrderMachineDefinition };
47 |
--------------------------------------------------------------------------------
/src/L1/Order_Final.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine } from "xstate";
3 | import Order, { getOrderMachineDefinition } from "./Order";
4 | import { render, RenderResult, fireEvent, wait } from "@testing-library/react";
5 | import React from "react";
6 |
7 | ////////////////////////////////////////////////////////////////////////////////
8 | // Basic MBT UI Tests - FINAL
9 | ////////////////////////////////////////////////////////////////////////////////
10 |
11 | const getEventConfigs = () => {
12 | const eventConfigs = {
13 | ADD_TO_CART: {
14 | exec: async ({ getByText }: RenderResult) => {
15 | fireEvent.click(getByText("Add to cart"));
16 | },
17 | },
18 | PLACE_ORDER: {
19 | exec: async ({ getByText }: RenderResult) => {
20 | fireEvent.click(getByText("Place Order"));
21 | },
22 | },
23 | };
24 |
25 | return eventConfigs;
26 | };
27 |
28 | const shoppingTest = {
29 | test: async ({ getByText }: RenderResult) => {
30 | await wait(() => expect(() => getByText("shopping")).not.toThrowError());
31 | },
32 | };
33 | const cartTest = {
34 | test: async ({ getByText }: RenderResult) => {
35 | await wait(() => expect(() => getByText("cart")).not.toThrowError());
36 | },
37 | };
38 | const orderedTest = {
39 | test: async ({ getByText }: RenderResult) => {
40 | await wait(() => expect(() => getByText("ordered")).not.toThrowError());
41 | },
42 | };
43 |
44 | describe("Order", () => {
45 | describe("matches all paths", () => {
46 | const testMachineDefinition = getOrderMachineDefinition();
47 |
48 | (testMachineDefinition.states.shopping as any).meta = shoppingTest;
49 | (testMachineDefinition.states.cart as any).meta = cartTest;
50 | (testMachineDefinition.states.ordered as any).meta = orderedTest;
51 |
52 | const testMachine = Machine(testMachineDefinition);
53 |
54 | const testModel = createModel(testMachine).withEvents(
55 | getEventConfigs() as any
56 | );
57 |
58 | const testPlans = testModel.getShortestPathPlans();
59 |
60 | testPlans.forEach((plan) => {
61 | describe(plan.description, () => {
62 | plan.paths.forEach((path) => {
63 | it(path.description, async () => {
64 | await path.test(render());
65 | });
66 | });
67 | });
68 | });
69 |
70 | it("should have full coverage", () => {
71 | return testModel.testCoverage();
72 | });
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/src/L2/Order.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine, assign } from "xstate";
3 | import Order from "./Order";
4 | import { render, RenderResult, fireEvent, wait } from "@testing-library/react";
5 | import React from "react";
6 |
7 | ////////////////////////////////////////////////////////////////////////////////
8 | // Using Test Machine Context to repeat paths
9 | ////////////////////////////////////////////////////////////////////////////////
10 |
11 | // 1. Add Event
12 | // 2. Run tests, notice that the tests does not loop over previously visited states
13 | // 3. Context is consider part of a unique state, use context to allow the
14 | // test to iterated a second time.
15 | // 4. Woh! You'll need a filter on the path generation to stop it from looping forever
16 |
17 | // Context is considered a unique state
18 | const getTestMachine = () =>
19 | Machine({
20 | id: "order",
21 | initial: "shopping",
22 | context: {},
23 | states: {
24 | shopping: { on: { ADD_TO_CART: "cart" } },
25 | cart: { on: { PLACE_ORDER: "ordered" } },
26 | ordered: { on: { CONTINUE_SHOPPING: "shopping" } },
27 | },
28 | });
29 |
30 | const getEventConfigs = () => {
31 | const eventConfigs = {
32 | ADD_TO_CART: {
33 | exec: async ({ getByText }: RenderResult) => {
34 | fireEvent.click(getByText("Add to cart"));
35 | },
36 | },
37 | PLACE_ORDER: {
38 | exec: async ({ getByText }: RenderResult) => {
39 | fireEvent.click(getByText("Place Order"));
40 | },
41 | },
42 | // New Event
43 | };
44 |
45 | return eventConfigs;
46 | };
47 |
48 | const shoppingTest = {
49 | test: async ({ getByText }: RenderResult) => {
50 | await wait(() => expect(() => getByText("shopping")).not.toThrowError());
51 | },
52 | };
53 | const cartTest = {
54 | test: async ({ getByText }: RenderResult) => {
55 | await wait(() => expect(() => getByText("cart")).not.toThrowError());
56 | },
57 | };
58 | const orderedTest = {
59 | test: async ({ getByText }: RenderResult) => {
60 | await wait(() => expect(() => getByText("ordered")).not.toThrowError());
61 | },
62 | };
63 |
64 | describe("Order", () => {
65 | describe("matches all paths", () => {
66 | const testMachine = getTestMachine();
67 |
68 | (testMachine.states.shopping as any).meta = shoppingTest;
69 | (testMachine.states.cart as any).meta = cartTest;
70 | (testMachine.states.ordered as any).meta = orderedTest;
71 |
72 | const testModel = createModel(testMachine).withEvents(
73 | getEventConfigs() as any
74 | );
75 |
76 | // Add filter to handle infinite iterations
77 | const testPlans = testModel.getShortestPathPlans();
78 |
79 | testPlans.forEach((plan) => {
80 | describe(plan.description, () => {
81 | plan.paths.forEach((path) => {
82 | it(path.description, async () => {
83 | await path.test(render());
84 | });
85 | });
86 | });
87 | });
88 |
89 | it("should have full coverage", () => {
90 | return testModel.testCoverage();
91 | });
92 | });
93 | });
94 |
--------------------------------------------------------------------------------
/src/L2/Order.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "../Order.css";
3 | import Button from "@material-ui/core/Button";
4 | import { Machine } from "xstate";
5 | import { useMachine } from "@xstate/react";
6 |
7 | const getOrderMachineDefinition = () => ({
8 | id: "order",
9 | initial: "shopping",
10 | states: {
11 | shopping: { on: { ADD_TO_CART: "cart" } },
12 | cart: { on: { PLACE_ORDER: "ordered" } },
13 | // Added transition to loop back to the start of the state machine
14 | ordered: { on: { CONTINUE_SHOPPING: "shopping" } },
15 | },
16 | });
17 |
18 | const Order: React.FC = () => {
19 | const [orderMachineState, send] = useMachine(
20 | Machine(getOrderMachineDefinition())
21 | );
22 |
23 | return (
24 |
25 |
{orderMachineState.value}
26 |
27 | {orderMachineState.value === "shopping" && (
28 |
35 | )}
36 | {orderMachineState.value === "cart" && (
37 |
44 | )}
45 | {orderMachineState.value === "ordered" && (
46 |
49 | )}
50 |
51 |
52 | );
53 | };
54 |
55 | export default Order;
56 | export { getOrderMachineDefinition };
57 |
--------------------------------------------------------------------------------
/src/L2/Order_Final.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine, assign } from "xstate";
3 | import Order from "./Order";
4 | import { render, RenderResult, fireEvent, wait } from "@testing-library/react";
5 | import React from "react";
6 |
7 | ////////////////////////////////////////////////////////////////////////////////
8 | // Using Test Machine Context to repeat paths - FINAL
9 | ////////////////////////////////////////////////////////////////////////////////
10 |
11 | const getTestMachine = () =>
12 | Machine(
13 | {
14 | id: "order",
15 | initial: "shopping",
16 | context: {
17 | ordersCompleted: 0,
18 | },
19 | states: {
20 | shopping: { on: { ADD_TO_CART: "cart" } },
21 | cart: { on: { PLACE_ORDER: "ordered" } },
22 | ordered: {
23 | on: {
24 | CONTINUE_SHOPPING: {
25 | actions: ["orderCompleted"],
26 | target: "shopping",
27 | },
28 | },
29 | },
30 | },
31 | },
32 | {
33 | actions: {
34 | orderCompleted: assign((context) => ({
35 | ordersCompleted: context.ordersCompleted + 1,
36 | })),
37 | },
38 | }
39 | );
40 |
41 | const getEventConfigs = () => {
42 | const eventConfigs = {
43 | ADD_TO_CART: {
44 | exec: async ({ getByText }: RenderResult) => {
45 | fireEvent.click(getByText("Add to cart"));
46 | },
47 | },
48 | PLACE_ORDER: {
49 | exec: async ({ getByText }: RenderResult) => {
50 | fireEvent.click(getByText("Place Order"));
51 | },
52 | },
53 | CONTINUE_SHOPPING: {
54 | exec: async ({ getByText }: RenderResult) => {
55 | fireEvent.click(getByText("Continue Shopping"));
56 | },
57 | },
58 | };
59 |
60 | return eventConfigs;
61 | };
62 |
63 | const shoppingTest = {
64 | test: async ({ getByText }: RenderResult) => {
65 | await wait(() => expect(() => getByText("shopping")).not.toThrowError());
66 | },
67 | };
68 | const cartTest = {
69 | test: async ({ getByText }: RenderResult) => {
70 | await wait(() => expect(() => getByText("cart")).not.toThrowError());
71 | },
72 | };
73 | const orderedTest = {
74 | test: async ({ getByText }: RenderResult) => {
75 | await wait(() => expect(() => getByText("ordered")).not.toThrowError());
76 | },
77 | };
78 |
79 | describe("Order", () => {
80 | describe("matches all paths", () => {
81 | const testMachine = getTestMachine();
82 |
83 | (testMachine.states.shopping as any).meta = shoppingTest;
84 | (testMachine.states.cart as any).meta = cartTest;
85 | (testMachine.states.ordered as any).meta = orderedTest;
86 |
87 | const testModel = createModel(testMachine).withEvents(
88 | getEventConfigs() as any
89 | );
90 |
91 | const testPlans = testModel.getShortestPathPlans({
92 | filter: (state) => state.context.ordersCompleted <= 1,
93 | });
94 |
95 | testPlans.forEach((plan) => {
96 | describe(plan.description, () => {
97 | plan.paths.forEach((path) => {
98 | it(path.description, async () => {
99 | await path.test(render());
100 | });
101 | });
102 | });
103 | });
104 |
105 | it("should have full coverage", () => {
106 | return testModel.testCoverage();
107 | });
108 | });
109 | });
110 |
--------------------------------------------------------------------------------
/src/L3/Order.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine, assign } from "xstate";
3 | import Order from "./Order";
4 | import { render, RenderResult, fireEvent, wait } from "@testing-library/react";
5 | import React from "react";
6 |
7 | ////////////////////////////////////////////////////////////////////////////////
8 | // Using Test Machine Context to explore non-shortest paths
9 | ////////////////////////////////////////////////////////////////////////////////
10 |
11 | // 1. Add cancel event to event config
12 | // 2. Update test machine to have cancel option
13 | // 3. Run the test, notice how the Cancel action is not taken
14 | // 4. Use context to make the test Cancel once
15 | // 5. Don't forget to filter path generation!
16 |
17 | const getTestMachine = () =>
18 | Machine(
19 | {
20 | id: "order",
21 | initial: "shopping",
22 | context: {
23 | ordersCompleted: 0,
24 | },
25 | states: {
26 | shopping: { on: { ADD_TO_CART: "cart" } },
27 | cart: { on: { PLACE_ORDER: "ordered" } },
28 | ordered: {
29 | on: {
30 | CONTINUE_SHOPPING: {
31 | actions: ["orderCompleted"],
32 | target: "shopping",
33 | },
34 | },
35 | },
36 | },
37 | },
38 | {
39 | actions: {
40 | orderCompleted: assign((context) => ({
41 | ordersCompleted: context.ordersCompleted + 1,
42 | })),
43 | },
44 | }
45 | );
46 |
47 | const getEventConfigs = () => {
48 | const eventConfigs = {
49 | ADD_TO_CART: {
50 | exec: async ({ getByText }: RenderResult) => {
51 | fireEvent.click(getByText("Add to cart"));
52 | },
53 | },
54 | PLACE_ORDER: {
55 | exec: async ({ getByText }: RenderResult) => {
56 | fireEvent.click(getByText("Place Order"));
57 | },
58 | },
59 | CONTINUE_SHOPPING: {
60 | exec: async ({ getByText }: RenderResult) => {
61 | fireEvent.click(getByText("Continue Shopping"));
62 | },
63 | },
64 | };
65 |
66 | return eventConfigs;
67 | };
68 |
69 | const shoppingTest = {
70 | test: async ({ getByText }: RenderResult) => {
71 | await wait(() => expect(() => getByText("shopping")).not.toThrowError());
72 | },
73 | };
74 | const cartTest = {
75 | test: async ({ getByText }: RenderResult) => {
76 | await wait(() => expect(() => getByText("cart")).not.toThrowError());
77 | },
78 | };
79 | const orderedTest = {
80 | test: async ({ getByText }: RenderResult) => {
81 | await wait(() => expect(() => getByText("ordered")).not.toThrowError());
82 | },
83 | };
84 |
85 | describe("Order", () => {
86 | describe("matches all paths", () => {
87 | const testMachine = getTestMachine();
88 |
89 | (testMachine.states.shopping as any).meta = shoppingTest;
90 | (testMachine.states.cart as any).meta = cartTest;
91 | (testMachine.states.ordered as any).meta = orderedTest;
92 |
93 | const testModel = createModel(testMachine).withEvents(
94 | getEventConfigs() as any
95 | );
96 |
97 | const testPlans = testModel.getShortestPathPlans({
98 | filter: (state) => state.context.ordersCompleted <= 1,
99 | });
100 |
101 | testPlans.forEach((plan) => {
102 | describe(plan.description, () => {
103 | plan.paths.forEach((path) => {
104 | it(path.description, async () => {
105 | await path.test(render());
106 | });
107 | });
108 | });
109 | });
110 |
111 | it("should have full coverage", () => {
112 | return testModel.testCoverage();
113 | });
114 | });
115 | });
116 |
--------------------------------------------------------------------------------
/src/L3/Order.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "../Order.css";
3 | import Button from "@material-ui/core/Button";
4 | import { Machine } from "xstate";
5 | import { useMachine } from "@xstate/react";
6 |
7 | const getOrderMachineDefinition = () => ({
8 | id: "order",
9 | initial: "shopping",
10 | states: {
11 | shopping: { on: { ADD_TO_CART: "cart" } },
12 | // Added cancel option
13 | cart: { on: { PLACE_ORDER: "ordered", CANCEL: "shopping" } },
14 | ordered: { on: { CONTINUE_SHOPPING: "shopping" } },
15 | },
16 | });
17 |
18 | const Order: React.FC = () => {
19 | const [orderMachineState, send] = useMachine(
20 | Machine(getOrderMachineDefinition())
21 | );
22 |
23 | return (
24 |
25 |
{orderMachineState.value}
26 |
27 | {orderMachineState.value === "shopping" && (
28 |
35 | )}
36 | {orderMachineState.value === "cart" && (
37 |
44 | )}
45 | {orderMachineState.value === "cart" && (
46 |
49 | )}
50 | {orderMachineState.value === "ordered" && (
51 |
54 | )}
55 |
56 |
57 | );
58 | };
59 |
60 | export default Order;
61 | export { getOrderMachineDefinition };
62 |
--------------------------------------------------------------------------------
/src/L3/Order_Final.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine, assign } from "xstate";
3 | import Order from "./Order";
4 | import { render, RenderResult, fireEvent, wait } from "@testing-library/react";
5 | import React from "react";
6 |
7 | ////////////////////////////////////////////////////////////////////////////////
8 | // Using Test Machine Context to explore non-shortest paths - FINAL
9 | ////////////////////////////////////////////////////////////////////////////////
10 |
11 | const getTestMachine = () =>
12 | Machine(
13 | {
14 | id: "order",
15 | initial: "shopping",
16 | context: {
17 | cartsCanceled: 0,
18 | ordersCompleted: 0,
19 | },
20 | states: {
21 | shopping: { on: { ADD_TO_CART: "cart" } },
22 | cart: {
23 | on: {
24 | PLACE_ORDER: "ordered",
25 | CANCEL: { actions: ["cartCanceled"], target: "shopping" },
26 | },
27 | },
28 | ordered: {
29 | on: {
30 | CONTINUE_SHOPPING: {
31 | actions: ["orderCompleted"],
32 | target: "shopping",
33 | },
34 | },
35 | },
36 | },
37 | },
38 | {
39 | actions: {
40 | cartCanceled: assign((context) => ({
41 | cartsCanceled: context.cartsCanceled + 1,
42 | })),
43 | orderCompleted: assign((context) => ({
44 | ordersCompleted: context.ordersCompleted + 1,
45 | })),
46 | },
47 | }
48 | );
49 |
50 | const getEventConfigs = () => {
51 | const eventConfigs = {
52 | ADD_TO_CART: {
53 | exec: async ({ getByText }: RenderResult) => {
54 | fireEvent.click(getByText("Add to cart"));
55 | },
56 | },
57 | PLACE_ORDER: {
58 | exec: async ({ getByText }: RenderResult) => {
59 | fireEvent.click(getByText("Place Order"));
60 | },
61 | },
62 | CONTINUE_SHOPPING: {
63 | exec: async ({ getByText }: RenderResult) => {
64 | fireEvent.click(getByText("Continue Shopping"));
65 | },
66 | },
67 | CANCEL: {
68 | exec: async ({ getByText }: RenderResult) => {
69 | fireEvent.click(getByText("Cancel"));
70 | },
71 | },
72 | };
73 |
74 | return eventConfigs;
75 | };
76 |
77 | const shoppingTest = {
78 | test: async ({ getByText }: RenderResult) => {
79 | await wait(() => expect(() => getByText("shopping")).not.toThrowError());
80 | },
81 | };
82 | const cartTest = {
83 | test: async ({ getByText }: RenderResult) => {
84 | await wait(() => expect(() => getByText("cart")).not.toThrowError());
85 | },
86 | };
87 | const orderedTest = {
88 | test: async ({ getByText }: RenderResult) => {
89 | await wait(() => expect(() => getByText("ordered")).not.toThrowError());
90 | },
91 | };
92 |
93 | describe("Order", () => {
94 | describe("matches all paths", () => {
95 | const testMachine = getTestMachine();
96 |
97 | (testMachine.states.shopping as any).meta = shoppingTest;
98 | (testMachine.states.cart as any).meta = cartTest;
99 | (testMachine.states.ordered as any).meta = orderedTest;
100 |
101 | const testModel = createModel(testMachine).withEvents(
102 | getEventConfigs() as any
103 | );
104 |
105 | const testPlans = testModel.getShortestPathPlans({
106 | filter: (state) =>
107 | state.context.ordersCompleted <= 1 && state.context.cartsCanceled <= 1,
108 | });
109 |
110 | testPlans.forEach((plan) => {
111 | describe(plan.description, () => {
112 | plan.paths.forEach((path) => {
113 | it(path.description, async () => {
114 | await path.test(render());
115 | });
116 | });
117 | });
118 | });
119 |
120 | it("should have full coverage", () => {
121 | return testModel.testCoverage();
122 | });
123 | });
124 | });
125 |
--------------------------------------------------------------------------------
/src/L4/Order.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine, assign } from "xstate";
3 | import Order from "./Order";
4 | import { render, RenderResult, fireEvent, wait } from "@testing-library/react";
5 | import React from "react";
6 |
7 | ////////////////////////////////////////////////////////////////////////////////
8 | // Testing Asynchronous Services
9 | ////////////////////////////////////////////////////////////////////////////////
10 |
11 | // 1. Copy in machine updates
12 | // Service is not needed in test machine, just src
13 | // But we do need to mock the UI service's Promise resolve and reject
14 | // Oh no! If the event for done/error happens after we already clicked "Place Order"
15 | // how do we mock?!
16 | // No worries, we can pass the promise through the Test Cycle and resolve/reject later
17 | // 2. Wrap render in an object so we can manage a context for the Test Cycle (TestCycleContext)
18 | // 3. Pass in all the needed properties
19 | // 4. Update exec functions to deconstruct from TestCycleContext
20 | // 5. Mock in PLACE_ORDER event before clicking,
21 | // setSubmitOrderCallbacks to the callbacks of the promised that is passed into mockReturnValueOnce
22 | // 6. Add Event for done.invoke.submitOrder to resolve
23 | // 7. Add Event for error.platform.submitOrder to reject
24 | // 8. Add Assertions for placingOrder state
25 | // 9. Add Assertions for orderFailed state
26 | // 10. Add context to ensure the order fails then succeeds in a path
27 | // 11. Add filter to avoid infinite loops
28 |
29 | type PromiseCallbacks = {
30 | resolve: (value?: any) => void;
31 | reject: (reason?: any) => void;
32 | };
33 |
34 | type Shared = {
35 | submitOrderCallbacks?: PromiseCallbacks;
36 | };
37 |
38 | type TestCycleContext = {
39 | target: RenderResult;
40 | shared: Shared;
41 | setSubmitOrderCallbacks: (submitOrderCallbacks: PromiseCallbacks) => void;
42 | submitOrderMock: jest.Mock;
43 | };
44 |
45 | const getTestMachine = () =>
46 | Machine(
47 | {
48 | id: "order",
49 | initial: "shopping",
50 | context: {
51 | cartsCanceled: 0,
52 | ordersCompleted: 0,
53 | },
54 | states: {
55 | shopping: { on: { ADD_TO_CART: "cart" } },
56 | cart: {
57 | on: {
58 | PLACE_ORDER: "ordered",
59 | CANCEL: { actions: ["cartCanceled"], target: "shopping" },
60 | },
61 | },
62 | ordered: {
63 | on: {
64 | CONTINUE_SHOPPING: {
65 | actions: ["orderCompleted"],
66 | target: "shopping",
67 | },
68 | },
69 | },
70 | },
71 | },
72 | {
73 | actions: {
74 | cartCanceled: assign((context) => ({
75 | cartsCanceled: context.cartsCanceled + 1,
76 | })),
77 | orderCompleted: assign((context) => ({
78 | ordersCompleted: context.ordersCompleted + 1,
79 | })),
80 | },
81 | }
82 | );
83 |
84 | const getEventConfigs = () => {
85 | const eventConfigs = {
86 | ADD_TO_CART: {
87 | exec: async ({ getByText }: RenderResult) => {
88 | fireEvent.click(getByText("Add to cart"));
89 | },
90 | },
91 | PLACE_ORDER: {
92 | exec: async ({ getByText }: RenderResult) => {
93 | fireEvent.click(getByText("Place Order"));
94 | },
95 | },
96 | CONTINUE_SHOPPING: {
97 | exec: async ({ getByText }: RenderResult) => {
98 | fireEvent.click(getByText("Continue Shopping"));
99 | },
100 | },
101 | CANCEL: {
102 | exec: async ({ getByText }: RenderResult) => {
103 | fireEvent.click(getByText("Cancel"));
104 | },
105 | },
106 | };
107 |
108 | return eventConfigs;
109 | };
110 |
111 | const shoppingTest = {
112 | test: async ({ getByText }: RenderResult) => {
113 | await wait(() => expect(() => getByText("shopping")).not.toThrowError());
114 | },
115 | };
116 | const cartTest = {
117 | test: async ({ getByText }: RenderResult) => {
118 | await wait(() => expect(() => getByText("cart")).not.toThrowError());
119 | },
120 | };
121 | const orderedTest = {
122 | test: async ({ getByText }: RenderResult) => {
123 | await wait(() => expect(() => getByText("ordered")).not.toThrowError());
124 | },
125 | };
126 |
127 | describe("Order", () => {
128 | describe("matches all paths", () => {
129 | const testMachine = getTestMachine();
130 |
131 | (testMachine.states.shopping as any).meta = shoppingTest;
132 | (testMachine.states.cart as any).meta = cartTest;
133 | (testMachine.states.ordered as any).meta = orderedTest;
134 |
135 | const testModel = createModel(testMachine).withEvents(
136 | getEventConfigs() as any
137 | );
138 |
139 | const testPlans = testModel
140 | .getShortestPathPlans({
141 | filter: (state) =>
142 | state.context.ordersCompleted <= 1 &&
143 | state.context.cartsCanceled <= 1,
144 | })
145 | // Added post-generation filter to reduce combinatorial explosion
146 | // 10 tests instead of 35 tests
147 | .filter(
148 | (plan) =>
149 | plan.state.context.ordersCompleted === 1 &&
150 | plan.state.context.cartsCanceled === 1
151 | );
152 |
153 | testPlans.forEach((plan) => {
154 | describe(plan.description, () => {
155 | plan.paths.forEach((path) => {
156 | it(path.description, async () => {
157 | // You'll need these for the TestCycleContext
158 | const submitOrderMock = jest.fn();
159 |
160 | const shared: Shared = {};
161 |
162 | const setSubmitOrderCallbacks = (
163 | submitOrderCallbacks: PromiseCallbacks
164 | ) => {
165 | shared.submitOrderCallbacks = submitOrderCallbacks;
166 | };
167 |
168 | await path.test(render());
169 | });
170 | });
171 | });
172 | });
173 |
174 | it("should have full coverage", () => {
175 | return testModel.testCoverage();
176 | });
177 | });
178 | });
179 |
--------------------------------------------------------------------------------
/src/L4/Order.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "../Order.css";
3 | import Button from "@material-ui/core/Button";
4 | import { Machine } from "xstate";
5 | import { useMachine } from "@xstate/react";
6 |
7 | type OrderServices = {
8 | submitOrder: () => Promise;
9 | };
10 |
11 | // Makeshift dependency injection to simplify tutorial
12 | type OrderProps = {
13 | services?: OrderServices;
14 | };
15 |
16 | const getServices = (): OrderServices => ({
17 | // Added service to simulate asynchronous calls to external applications
18 | submitOrder: () => {
19 | const delay = 1000;
20 |
21 | if (Math.random() < 0.5) {
22 | return new Promise((resolve) => setTimeout(resolve, delay));
23 | }
24 | return new Promise((_, reject) =>
25 | setTimeout(() => reject(new Error("Order Failed")), delay)
26 | );
27 | },
28 | });
29 |
30 | const createOrderMachine = (services: OrderServices) =>
31 | Machine(
32 | {
33 | id: "order",
34 | initial: "shopping",
35 | states: {
36 | shopping: { on: { ADD_TO_CART: "cart" } },
37 | cart: { on: { PLACE_ORDER: "placingOrder", CANCEL: "shopping" } },
38 | // Waiting state
39 | placingOrder: {
40 | invoke: {
41 | src: "submitOrder",
42 | onDone: "ordered",
43 | onError: "orderFailed",
44 | },
45 | },
46 | // Failed state
47 | orderFailed: {
48 | on: { PLACE_ORDER: "placingOrder", CANCEL: "shopping" },
49 | },
50 | ordered: { on: { CONTINUE_SHOPPING: "shopping" } },
51 | },
52 | },
53 | {
54 | services: {
55 | ...services,
56 | },
57 | }
58 | );
59 |
60 | const Order: React.FC = ({ services }) => {
61 | const [orderMachineState, send] = useMachine(
62 | createOrderMachine(services ?? getServices())
63 | );
64 |
65 | return (
66 |
67 |
{orderMachineState.value}
68 |
69 | {orderMachineState.value === "shopping" && (
70 |
77 | )}
78 | {(orderMachineState.value === "cart" ||
79 | orderMachineState.value === "orderFailed") && (
80 |
87 | )}
88 | {(orderMachineState.value === "cart" ||
89 | orderMachineState.value === "orderFailed") && (
90 |
93 | )}
94 | {orderMachineState.value === "ordered" && (
95 |
98 | )}
99 |
100 |
101 | );
102 | };
103 |
104 | export default Order;
105 |
--------------------------------------------------------------------------------
/src/L4/Order_Final.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine, assign } from "xstate";
3 | import Order from "./Order";
4 | import { render, RenderResult, fireEvent, wait } from "@testing-library/react";
5 | import React from "react";
6 |
7 | ////////////////////////////////////////////////////////////////////////////////
8 | // Testing Asynchronous Services - FINAL
9 | ////////////////////////////////////////////////////////////////////////////////
10 |
11 | type PromiseCallbacks = {
12 | resolve: (value?: any) => void;
13 | reject: (reason?: any) => void;
14 | };
15 |
16 | type Shared = {
17 | submitOrderCallbacks?: PromiseCallbacks;
18 | };
19 |
20 | type TestCycleContext = {
21 | target: RenderResult;
22 | shared: Shared;
23 | setSubmitOrderCallbacks: (submitOrderCallbacks: PromiseCallbacks) => void;
24 | submitOrderMock: jest.Mock;
25 | };
26 |
27 | const getTestMachine = () =>
28 | Machine(
29 | {
30 | id: "order",
31 | initial: "shopping",
32 | context: {
33 | cartsCanceled: 0,
34 | ordersCompleted: 0,
35 | ordersFailed: 0,
36 | },
37 | states: {
38 | shopping: { on: { ADD_TO_CART: "cart" } },
39 | cart: {
40 | on: {
41 | PLACE_ORDER: "placingOrder",
42 | CANCEL: { actions: ["cartCanceled"], target: "shopping" },
43 | },
44 | },
45 | placingOrder: {
46 | invoke: {
47 | src: "submitOrder",
48 | onDone: "ordered",
49 | onError: { actions: ["orderFailed"], target: "orderFailed" },
50 | },
51 | },
52 | orderFailed: {
53 | on: {
54 | PLACE_ORDER: "placingOrder",
55 | CANCEL: { actions: ["cartCanceled"], target: "shopping" },
56 | },
57 | },
58 | ordered: {
59 | on: {
60 | CONTINUE_SHOPPING: {
61 | actions: ["orderCompleted"],
62 | target: "shopping",
63 | },
64 | },
65 | },
66 | },
67 | },
68 | {
69 | actions: {
70 | cartCanceled: assign((context) => ({
71 | cartsCanceled: context.cartsCanceled + 1,
72 | })),
73 | orderCompleted: assign((context) => ({
74 | ordersCompleted: context.ordersCompleted + 1,
75 | })),
76 | orderFailed: assign((context) => ({
77 | ordersFailed: context.ordersFailed + 1,
78 | })),
79 | },
80 | }
81 | );
82 |
83 | const getEventConfigs = () => {
84 | const eventConfigs = {
85 | ADD_TO_CART: {
86 | exec: async ({ target: { getByText } }: TestCycleContext) => {
87 | fireEvent.click(getByText("Add to cart"));
88 | },
89 | },
90 | PLACE_ORDER: {
91 | exec: async ({
92 | target: { getByText },
93 | submitOrderMock,
94 | setSubmitOrderCallbacks,
95 | }: TestCycleContext) => {
96 | const submitOrderPromise = new Promise((resolve, reject) => {
97 | setSubmitOrderCallbacks({ resolve, reject });
98 | });
99 |
100 | submitOrderMock.mockReturnValueOnce(submitOrderPromise);
101 |
102 | fireEvent.click(getByText("Place Order"));
103 | },
104 | },
105 | "done.invoke.submitOrder": {
106 | exec: async ({ shared: { submitOrderCallbacks } }: TestCycleContext) => {
107 | if (submitOrderCallbacks) {
108 | submitOrderCallbacks.resolve();
109 | }
110 | },
111 | },
112 | "error.platform.submitOrder": {
113 | exec: async ({ shared: { submitOrderCallbacks } }: TestCycleContext) => {
114 | if (submitOrderCallbacks) {
115 | submitOrderCallbacks.reject(new Error());
116 | }
117 | },
118 | },
119 | CONTINUE_SHOPPING: {
120 | exec: async ({ target: { getByText } }: TestCycleContext) => {
121 | fireEvent.click(getByText("Continue Shopping"));
122 | },
123 | },
124 | CANCEL: {
125 | exec: async ({ target: { getByText } }: TestCycleContext) => {
126 | fireEvent.click(getByText("Cancel"));
127 | },
128 | },
129 | };
130 |
131 | return eventConfigs;
132 | };
133 |
134 | const shoppingTest = {
135 | test: async ({ target: { getByText } }: TestCycleContext) => {
136 | await wait(() => expect(() => getByText("shopping")).not.toThrowError());
137 | },
138 | };
139 | const cartTest = {
140 | test: async ({ target: { getByText } }: TestCycleContext) => {
141 | await wait(() => expect(() => getByText("cart")).not.toThrowError());
142 | },
143 | };
144 | const orderFailedTest = {
145 | test: async ({ target: { getByText } }: TestCycleContext) => {
146 | await wait(() => expect(() => getByText("orderFailed")).not.toThrowError());
147 | },
148 | };
149 | const placingOrderTest = {
150 | test: async ({ target: { getByText } }: TestCycleContext) => {
151 | await wait(() =>
152 | expect(() => getByText("placingOrder")).not.toThrowError()
153 | );
154 | },
155 | };
156 | const orderedTest = {
157 | test: async ({ target: { getByText } }: TestCycleContext) => {
158 | await wait(() => expect(() => getByText("ordered")).not.toThrowError());
159 | },
160 | };
161 |
162 | describe("Order", () => {
163 | describe("matches all paths", () => {
164 | const testMachine = getTestMachine();
165 |
166 | (testMachine.states.shopping as any).meta = shoppingTest;
167 | (testMachine.states.cart as any).meta = cartTest;
168 | (testMachine.states.orderFailed as any).meta = orderFailedTest;
169 | (testMachine.states.placingOrder as any).meta = placingOrderTest;
170 | (testMachine.states.ordered as any).meta = orderedTest;
171 |
172 | const testModel = createModel(testMachine).withEvents(
173 | getEventConfigs() as any
174 | );
175 |
176 | const testPlans = testModel
177 | .getShortestPathPlans({
178 | filter: (state) =>
179 | state.context.ordersCompleted <= 1 &&
180 | state.context.cartsCanceled <= 1 &&
181 | state.context.ordersFailed <= 1,
182 | })
183 | .filter(
184 | (plan) =>
185 | plan.state.context.ordersCompleted === 1 &&
186 | plan.state.context.cartsCanceled === 1
187 | );
188 |
189 | testPlans.forEach((plan) => {
190 | describe(plan.description, () => {
191 | plan.paths.forEach((path) => {
192 | it(path.description, async () => {
193 | const submitOrderMock = jest.fn();
194 |
195 | const shared: Shared = {};
196 |
197 | const setSubmitOrderCallbacks = (
198 | submitOrderCallbacks: PromiseCallbacks
199 | ) => {
200 | shared.submitOrderCallbacks = submitOrderCallbacks;
201 | };
202 |
203 | await path.test({
204 | target: render(
205 |
206 | ),
207 | shared,
208 | setSubmitOrderCallbacks,
209 | submitOrderMock,
210 | } as TestCycleContext);
211 | });
212 | });
213 | });
214 | });
215 |
216 | it("should have full coverage", () => {
217 | return testModel.testCoverage();
218 | });
219 | });
220 | });
221 |
--------------------------------------------------------------------------------
/src/L5/OrderMachine.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine, assign, StateMachine, State } from "xstate";
3 | import createOrderMachine from "./OrderMachine";
4 |
5 | ////////////////////////////////////////////////////////////////////////////////
6 | // Model Based Testing the State Machine
7 | ////////////////////////////////////////////////////////////////////////////////
8 |
9 | // The best way to test State Machines is to utilize the pure function calls https://xstate.js.org/docs/guides/transitions.html#machine-transition-method
10 | // Here's what's changed in the setup
11 | // * The render() target was replaced with the pure StateMachine
12 | // * currentState was added to TestCycleContext.shared
13 | // * setCurrentStateWithActions was added to update currentState and execute action side effects, if any
14 | // * References to the UI were removed
15 | //
16 | // 1. Update Event Configs to transition from the current state using the StateMachine,
17 | // save the new state and execute side effects with setCurrentStateWithActions
18 | // 2. Update the Assertions to validate the currentState.
19 | // Here you would also validate the context, but in this case there is none.
20 |
21 | const executeActions = (state: State) => {
22 | const { actions, context, _event } = state;
23 |
24 | actions.forEach((action) => {
25 | // eslint-disable-next-line no-unused-expressions
26 | action.exec && action.exec(context, _event.data, undefined as any);
27 | });
28 | };
29 |
30 | type PromiseCallbacks = {
31 | resolve: (value?: any) => void;
32 | reject: (reason?: any) => void;
33 | };
34 |
35 | type Shared = {
36 | currentState: State;
37 | submitOrderCallbacks?: PromiseCallbacks;
38 | };
39 |
40 | type TestCycleContext = {
41 | stateMachine: StateMachine;
42 | shared: Shared;
43 | setCurrentStateWithActions: (state: State) => void;
44 | setSubmitOrderCallbacks: (submitOrderCallbacks: PromiseCallbacks) => void;
45 | submitOrderMock: jest.Mock;
46 | };
47 |
48 | const getTestMachine = () =>
49 | Machine(
50 | {
51 | id: "order",
52 | initial: "shopping",
53 | context: {
54 | cartsCanceled: 0,
55 | ordersCompleted: 0,
56 | ordersFailed: 0,
57 | },
58 | states: {
59 | shopping: { on: { ADD_TO_CART: "cart" } },
60 | cart: {
61 | on: {
62 | PLACE_ORDER: "placingOrder",
63 | CANCEL: { actions: ["cartCanceled"], target: "shopping" },
64 | },
65 | },
66 | placingOrder: {
67 | invoke: {
68 | src: "submitOrder",
69 | onDone: "ordered",
70 | onError: { actions: ["orderFailed"], target: "orderFailed" },
71 | },
72 | },
73 | orderFailed: {
74 | on: {
75 | PLACE_ORDER: "placingOrder",
76 | CANCEL: { actions: ["cartCanceled"], target: "shopping" },
77 | },
78 | },
79 | ordered: {
80 | on: {
81 | CONTINUE_SHOPPING: {
82 | actions: ["orderCompleted"],
83 | target: "shopping",
84 | },
85 | },
86 | },
87 | },
88 | },
89 | {
90 | actions: {
91 | cartCanceled: assign((context) => ({
92 | cartsCanceled: context.cartsCanceled + 1,
93 | })),
94 | orderCompleted: assign((context) => ({
95 | ordersCompleted: context.ordersCompleted + 1,
96 | })),
97 | orderFailed: assign((context) => ({
98 | ordersFailed: context.ordersFailed + 1,
99 | })),
100 | },
101 | }
102 | );
103 |
104 | const getEventConfigs = () => {
105 | const eventConfigs = {
106 | ADD_TO_CART: {
107 | exec: async ({
108 | stateMachine,
109 | shared: { currentState },
110 | setCurrentStateWithActions,
111 | }: TestCycleContext) => {},
112 | },
113 | PLACE_ORDER: {
114 | exec: async ({
115 | stateMachine,
116 | shared: { currentState },
117 | submitOrderMock,
118 | setSubmitOrderCallbacks,
119 | setCurrentStateWithActions,
120 | }: TestCycleContext) => {
121 | const submitOrderPromise = new Promise((resolve, reject) => {
122 | setSubmitOrderCallbacks({ resolve, reject });
123 | }).catch(() => {}); // Use catch to satisfy UnhandledPromiseRejectionWarning
124 |
125 | submitOrderMock.mockReturnValueOnce(submitOrderPromise);
126 | },
127 | },
128 | "done.invoke.submitOrder": {
129 | exec: async ({
130 | stateMachine,
131 | shared: { currentState, submitOrderCallbacks },
132 | setCurrentStateWithActions,
133 | }: TestCycleContext) => {
134 | if (submitOrderCallbacks) {
135 | submitOrderCallbacks.resolve();
136 | }
137 | },
138 | },
139 | "error.platform.submitOrder": {
140 | exec: async ({
141 | stateMachine,
142 | shared: { currentState, submitOrderCallbacks },
143 | setCurrentStateWithActions,
144 | }: TestCycleContext) => {
145 | if (submitOrderCallbacks) {
146 | submitOrderCallbacks.reject(new Error());
147 | }
148 | },
149 | },
150 | CONTINUE_SHOPPING: {
151 | exec: async ({
152 | stateMachine,
153 | shared: { currentState },
154 | setCurrentStateWithActions,
155 | }: TestCycleContext) => {},
156 | },
157 | CANCEL: {
158 | exec: async ({
159 | stateMachine,
160 | shared: { currentState },
161 | setCurrentStateWithActions,
162 | }: TestCycleContext) => {},
163 | },
164 | };
165 |
166 | return eventConfigs;
167 | };
168 |
169 | const shoppingTest = {
170 | test: ({ shared: { currentState } }: TestCycleContext) => {},
171 | };
172 | const cartTest = {
173 | test: ({ shared: { currentState } }: TestCycleContext) => {},
174 | };
175 | const orderFailedTest = {
176 | test: ({ shared: { currentState } }: TestCycleContext) => {},
177 | };
178 | const placingOrderTest = {
179 | test: ({ shared: { currentState } }: TestCycleContext) => {},
180 | };
181 | const orderedTest = {
182 | test: ({ shared: { currentState } }: TestCycleContext) => {},
183 | };
184 |
185 | describe("Order", () => {
186 | describe("matches all paths", () => {
187 | const testMachine = getTestMachine();
188 |
189 | (testMachine.states.shopping as any).meta = shoppingTest;
190 | (testMachine.states.cart as any).meta = cartTest;
191 | (testMachine.states.orderFailed as any).meta = orderFailedTest;
192 | (testMachine.states.placingOrder as any).meta = placingOrderTest;
193 | (testMachine.states.ordered as any).meta = orderedTest;
194 |
195 | const testModel = createModel(testMachine).withEvents(
196 | getEventConfigs() as any
197 | );
198 |
199 | const testPlans = testModel.getShortestPathPlans({
200 | filter: (state) =>
201 | state.context.ordersCompleted <= 1 &&
202 | state.context.cartsCanceled <= 1 &&
203 | state.context.ordersFailed <= 1,
204 | });
205 |
206 | testPlans.forEach((plan) => {
207 | describe(plan.description, () => {
208 | plan.paths.forEach((path) => {
209 | it(path.description, async () => {
210 | const submitOrderMock = jest.fn();
211 |
212 | const stateMachine = createOrderMachine({
213 | submitOrder: submitOrderMock,
214 | });
215 |
216 | const shared: Shared = { currentState: stateMachine.initialState };
217 |
218 | const setSubmitOrderCallbacks = (
219 | submitOrderCallbacks: PromiseCallbacks
220 | ) => {
221 | shared.submitOrderCallbacks = submitOrderCallbacks;
222 | };
223 |
224 | const setCurrentStateWithActions = (state: State) => {
225 | // Executing the actions is unnecessary in this case since this state machine has no actions.
226 | // But here it is anyways since it is such a common use case.
227 | executeActions(state);
228 | shared.currentState = state;
229 | };
230 |
231 | await path.test({
232 | stateMachine,
233 | shared,
234 | setSubmitOrderCallbacks,
235 | setCurrentStateWithActions,
236 | submitOrderMock,
237 | } as TestCycleContext);
238 | });
239 | });
240 | });
241 | });
242 |
243 | it("should have full coverage", () => {
244 | return testModel.testCoverage();
245 | });
246 | });
247 | });
248 |
--------------------------------------------------------------------------------
/src/L5/OrderMachine.tsx:
--------------------------------------------------------------------------------
1 | import "../Order.css";
2 | import { Machine } from "xstate";
3 |
4 | type OrderServices = {
5 | submitOrder: () => Promise;
6 | };
7 |
8 | const createOrderMachine = (services: OrderServices) =>
9 | Machine(
10 | {
11 | id: "order",
12 | initial: "shopping",
13 | states: {
14 | shopping: { on: { ADD_TO_CART: "cart" } },
15 | cart: { on: { PLACE_ORDER: "placingOrder", CANCEL: "shopping" } },
16 | placingOrder: {
17 | invoke: {
18 | src: "submitOrder",
19 | onDone: "ordered",
20 | onError: "orderFailed",
21 | },
22 | },
23 | orderFailed: {
24 | on: { PLACE_ORDER: "placingOrder", CANCEL: "shopping" },
25 | },
26 | ordered: { on: { CONTINUE_SHOPPING: "shopping" } },
27 | },
28 | },
29 | {
30 | services: {
31 | ...services,
32 | },
33 | }
34 | );
35 |
36 | export default createOrderMachine;
37 |
--------------------------------------------------------------------------------
/src/L5/OrderMachine_Final.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine, assign, StateMachine, State } from "xstate";
3 | import createOrderMachine from "./OrderMachine";
4 |
5 | ////////////////////////////////////////////////////////////////////////////////
6 | // Model Based Testing the State Machine - FINAL
7 | ////////////////////////////////////////////////////////////////////////////////
8 |
9 | const executeActions = (state: State) => {
10 | const { actions, context, _event } = state;
11 |
12 | actions.forEach((action) => {
13 | // eslint-disable-next-line no-unused-expressions
14 | action.exec && action.exec(context, _event.data, undefined as any);
15 | });
16 | };
17 |
18 | type PromiseCallbacks = {
19 | resolve: (value?: any) => void;
20 | reject: (reason?: any) => void;
21 | };
22 |
23 | type Shared = {
24 | currentState: State;
25 | submitOrderCallbacks?: PromiseCallbacks;
26 | };
27 |
28 | type TestCycleContext = {
29 | stateMachine: StateMachine;
30 | shared: Shared;
31 | setCurrentStateWithActions: (state: State) => void;
32 | setSubmitOrderCallbacks: (submitOrderCallbacks: PromiseCallbacks) => void;
33 | submitOrderMock: jest.Mock;
34 | };
35 |
36 | const getTestMachine = () =>
37 | Machine(
38 | {
39 | id: "order",
40 | initial: "shopping",
41 | context: {
42 | cartsCanceled: 0,
43 | ordersCompleted: 0,
44 | ordersFailed: 0,
45 | },
46 | states: {
47 | shopping: { on: { ADD_TO_CART: "cart" } },
48 | cart: {
49 | on: {
50 | PLACE_ORDER: "placingOrder",
51 | CANCEL: { actions: ["cartCanceled"], target: "shopping" },
52 | },
53 | },
54 | placingOrder: {
55 | invoke: {
56 | src: "submitOrder",
57 | onDone: "ordered",
58 | onError: { actions: ["orderFailed"], target: "orderFailed" },
59 | },
60 | },
61 | orderFailed: {
62 | on: {
63 | PLACE_ORDER: "placingOrder",
64 | CANCEL: { actions: ["cartCanceled"], target: "shopping" },
65 | },
66 | },
67 | ordered: {
68 | on: {
69 | CONTINUE_SHOPPING: {
70 | actions: ["orderCompleted"],
71 | target: "shopping",
72 | },
73 | },
74 | },
75 | },
76 | },
77 | {
78 | actions: {
79 | cartCanceled: assign((context) => ({
80 | cartsCanceled: context.cartsCanceled + 1,
81 | })),
82 | orderCompleted: assign((context) => ({
83 | ordersCompleted: context.ordersCompleted + 1,
84 | })),
85 | orderFailed: assign((context) => ({
86 | ordersFailed: context.ordersFailed + 1,
87 | })),
88 | },
89 | }
90 | );
91 |
92 | const getEventConfigs = () => {
93 | const eventConfigs = {
94 | ADD_TO_CART: {
95 | exec: async ({
96 | stateMachine,
97 | shared: { currentState },
98 | setCurrentStateWithActions,
99 | }: TestCycleContext) => {
100 | setCurrentStateWithActions(
101 | stateMachine.transition(currentState, {
102 | type: "ADD_TO_CART",
103 | })
104 | );
105 | },
106 | },
107 | PLACE_ORDER: {
108 | exec: async ({
109 | stateMachine,
110 | shared: { currentState },
111 | submitOrderMock,
112 | setSubmitOrderCallbacks,
113 | setCurrentStateWithActions,
114 | }: TestCycleContext) => {
115 | const submitOrderPromise = new Promise((resolve, reject) => {
116 | setSubmitOrderCallbacks({ resolve, reject });
117 | }).catch(() => {}); // Use catch to satisfy UnhandledPromiseRejectionWarning
118 |
119 | submitOrderMock.mockReturnValueOnce(submitOrderPromise);
120 |
121 | setCurrentStateWithActions(
122 | stateMachine.transition(currentState, {
123 | type: "PLACE_ORDER",
124 | })
125 | );
126 | },
127 | },
128 | "done.invoke.submitOrder": {
129 | exec: async ({
130 | stateMachine,
131 | shared: { currentState, submitOrderCallbacks },
132 | setCurrentStateWithActions,
133 | }: TestCycleContext) => {
134 | if (submitOrderCallbacks) {
135 | submitOrderCallbacks.resolve();
136 |
137 | setCurrentStateWithActions(
138 | stateMachine.transition(currentState, {
139 | type: "done.invoke.submitOrder",
140 | })
141 | );
142 | }
143 | },
144 | },
145 | "error.platform.submitOrder": {
146 | exec: async ({
147 | stateMachine,
148 | shared: { currentState, submitOrderCallbacks },
149 | setCurrentStateWithActions,
150 | }: TestCycleContext) => {
151 | if (submitOrderCallbacks) {
152 | submitOrderCallbacks.reject(new Error());
153 |
154 | setCurrentStateWithActions(
155 | stateMachine.transition(currentState, {
156 | type: "error.platform.submitOrder",
157 | })
158 | );
159 | }
160 | },
161 | },
162 | CONTINUE_SHOPPING: {
163 | exec: async ({
164 | stateMachine,
165 | shared: { currentState },
166 | setCurrentStateWithActions,
167 | }: TestCycleContext) => {
168 | setCurrentStateWithActions(
169 | stateMachine.transition(currentState, {
170 | type: "CONTINUE_SHOPPING",
171 | })
172 | );
173 | },
174 | },
175 | CANCEL: {
176 | exec: async ({
177 | stateMachine,
178 | shared: { currentState },
179 | setCurrentStateWithActions,
180 | }: TestCycleContext) => {
181 | setCurrentStateWithActions(
182 | stateMachine.transition(currentState, {
183 | type: "CANCEL",
184 | })
185 | );
186 | },
187 | },
188 | };
189 |
190 | return eventConfigs;
191 | };
192 |
193 | const shoppingTest = {
194 | test: ({ shared: { currentState } }: TestCycleContext) => {
195 | expect(currentState.value).toBe("shopping");
196 | },
197 | };
198 | const cartTest = {
199 | test: ({ shared: { currentState } }: TestCycleContext) => {
200 | expect(currentState.value).toBe("cart");
201 | },
202 | };
203 | const orderFailedTest = {
204 | test: ({ shared: { currentState } }: TestCycleContext) => {
205 | expect(currentState.value).toBe("orderFailed");
206 | },
207 | };
208 | const placingOrderTest = {
209 | test: ({ shared: { currentState } }: TestCycleContext) => {
210 | expect(currentState.value).toBe("placingOrder");
211 | },
212 | };
213 | const orderedTest = {
214 | test: ({ shared: { currentState } }: TestCycleContext) => {
215 | expect(currentState.value).toBe("ordered");
216 | },
217 | };
218 |
219 | describe("Order", () => {
220 | describe("matches all paths", () => {
221 | const testMachine = getTestMachine();
222 |
223 | (testMachine.states.shopping as any).meta = shoppingTest;
224 | (testMachine.states.cart as any).meta = cartTest;
225 | (testMachine.states.orderFailed as any).meta = orderFailedTest;
226 | (testMachine.states.placingOrder as any).meta = placingOrderTest;
227 | (testMachine.states.ordered as any).meta = orderedTest;
228 |
229 | const testModel = createModel(testMachine).withEvents(
230 | getEventConfigs() as any
231 | );
232 |
233 | const testPlans = testModel.getShortestPathPlans({
234 | filter: (state) =>
235 | state.context.ordersCompleted <= 1 &&
236 | state.context.cartsCanceled <= 1 &&
237 | state.context.ordersFailed <= 1,
238 | });
239 |
240 | testPlans.forEach((plan) => {
241 | describe(plan.description, () => {
242 | plan.paths.forEach((path) => {
243 | it(path.description, async () => {
244 | const submitOrderMock = jest.fn();
245 |
246 | const stateMachine = createOrderMachine({
247 | submitOrder: submitOrderMock,
248 | });
249 |
250 | const shared: Shared = { currentState: stateMachine.initialState };
251 |
252 | const setSubmitOrderCallbacks = (
253 | submitOrderCallbacks: PromiseCallbacks
254 | ) => {
255 | shared.submitOrderCallbacks = submitOrderCallbacks;
256 | };
257 |
258 | const setCurrentStateWithActions = (state: State) => {
259 | // Executing the actions is unnecessary in this case since this state machine has no actions.
260 | // But here it is anyways since it is such a common use case.
261 | executeActions(state);
262 | shared.currentState = state;
263 | };
264 |
265 | await path.test({
266 | stateMachine,
267 | shared,
268 | setSubmitOrderCallbacks,
269 | setCurrentStateWithActions,
270 | submitOrderMock,
271 | } as TestCycleContext);
272 | });
273 | });
274 | });
275 | });
276 |
277 | it("should have full coverage", () => {
278 | return testModel.testCoverage();
279 | });
280 | });
281 | });
282 |
--------------------------------------------------------------------------------
/src/L6/Order.test.tsx:
--------------------------------------------------------------------------------
1 | import { createModel } from "@xstate/test";
2 | import { Machine, assign } from "xstate";
3 | import Order from "./Order";
4 | import { render, RenderResult, fireEvent, wait } from "@testing-library/react";
5 | import React from "react";
6 |
7 | ////////////////////////////////////////////////////////////////////////////////
8 | // Bonus - Freestyle
9 | ////////////////////////////////////////////////////////////////////////////////
10 |
11 | // Make your own changes to Order.tsx and update the tests to reflect the change.
12 | // Or do the reverse, TDD style.
13 | // Notice how you only have to make test updates that correlate 1-to-1 for each change,
14 | // unlike traditional tests for which you would need to update many or all the tests.
15 |
16 | type PromiseCallbacks = {
17 | resolve: (value?: any) => void;
18 | reject: (reason?: any) => void;
19 | };
20 |
21 | type Shared = {
22 | submitOrderCallbacks?: PromiseCallbacks;
23 | };
24 |
25 | type TestCycleContext = {
26 | target: RenderResult;
27 | shared: Shared;
28 | setSubmitOrderCallbacks: (submitOrderCallbacks: PromiseCallbacks) => void;
29 | submitOrderMock: jest.Mock;
30 | };
31 |
32 | const getTestMachine = () =>
33 | Machine(
34 | {
35 | id: "order",
36 | initial: "shopping",
37 | context: {
38 | cartsCanceled: 0,
39 | ordersCompleted: 0,
40 | ordersFailed: 0,
41 | },
42 | states: {
43 | shopping: { on: { ADD_TO_CART: "cart" } },
44 | cart: {
45 | on: {
46 | PLACE_ORDER: "placingOrder",
47 | CANCEL: { actions: ["cartCanceled"], target: "shopping" },
48 | },
49 | },
50 | placingOrder: {
51 | invoke: {
52 | src: "submitOrder",
53 | onDone: "ordered",
54 | onError: { actions: ["orderFailed"], target: "orderFailed" },
55 | },
56 | },
57 | orderFailed: {
58 | on: {
59 | PLACE_ORDER: "placingOrder",
60 | CANCEL: { actions: ["cartCanceled"], target: "shopping" },
61 | },
62 | },
63 | ordered: {
64 | on: {
65 | CONTINUE_SHOPPING: {
66 | actions: ["orderCompleted"],
67 | target: "shopping",
68 | },
69 | },
70 | },
71 | },
72 | },
73 | {
74 | actions: {
75 | cartCanceled: assign((context) => ({
76 | cartsCanceled: context.cartsCanceled + 1,
77 | })),
78 | orderCompleted: assign((context) => ({
79 | ordersCompleted: context.ordersCompleted + 1,
80 | })),
81 | orderFailed: assign((context) => ({
82 | ordersFailed: context.ordersFailed + 1,
83 | })),
84 | },
85 | }
86 | );
87 |
88 | const getEventConfigs = () => {
89 | const eventConfigs = {
90 | ADD_TO_CART: {
91 | exec: async ({ target: { getByText } }: TestCycleContext) => {
92 | fireEvent.click(getByText("Add to cart"));
93 | },
94 | },
95 | PLACE_ORDER: {
96 | exec: async ({
97 | target: { getByText },
98 | submitOrderMock,
99 | setSubmitOrderCallbacks,
100 | }: TestCycleContext) => {
101 | const submitOrderPromise = new Promise((resolve, reject) => {
102 | setSubmitOrderCallbacks({ resolve, reject });
103 | });
104 |
105 | submitOrderMock.mockReturnValueOnce(submitOrderPromise);
106 |
107 | fireEvent.click(getByText("Place Order"));
108 | },
109 | },
110 | "done.invoke.submitOrder": {
111 | exec: async ({ shared: { submitOrderCallbacks } }: TestCycleContext) => {
112 | if (submitOrderCallbacks) {
113 | submitOrderCallbacks.resolve();
114 | }
115 | },
116 | },
117 | "error.platform.submitOrder": {
118 | exec: async ({ shared: { submitOrderCallbacks } }: TestCycleContext) => {
119 | if (submitOrderCallbacks) {
120 | submitOrderCallbacks.reject(new Error());
121 | }
122 | },
123 | },
124 | CONTINUE_SHOPPING: {
125 | exec: async ({ target: { getByText } }: TestCycleContext) => {
126 | fireEvent.click(getByText("Continue Shopping"));
127 | },
128 | },
129 | CANCEL: {
130 | exec: async ({ target: { getByText } }: TestCycleContext) => {
131 | fireEvent.click(getByText("Cancel"));
132 | },
133 | },
134 | };
135 |
136 | return eventConfigs;
137 | };
138 |
139 | const shoppingTest = {
140 | test: async ({ target: { getByText } }: TestCycleContext) => {
141 | await wait(() => expect(() => getByText("shopping")).not.toThrowError());
142 | },
143 | };
144 | const cartTest = {
145 | test: async ({ target: { getByText } }: TestCycleContext) => {
146 | await wait(() => expect(() => getByText("cart")).not.toThrowError());
147 | },
148 | };
149 | const orderFailedTest = {
150 | test: async ({ target: { getByText } }: TestCycleContext) => {
151 | await wait(() => expect(() => getByText("orderFailed")).not.toThrowError());
152 | },
153 | };
154 | const placingOrderTest = {
155 | test: async ({ target: { getByText } }: TestCycleContext) => {
156 | await wait(() =>
157 | expect(() => getByText("placingOrder")).not.toThrowError()
158 | );
159 | },
160 | };
161 | const orderedTest = {
162 | test: async ({ target: { getByText } }: TestCycleContext) => {
163 | await wait(() => expect(() => getByText("ordered")).not.toThrowError());
164 | },
165 | };
166 |
167 | describe("Order", () => {
168 | describe("matches all paths", () => {
169 | const testMachine = getTestMachine();
170 |
171 | (testMachine.states.shopping as any).meta = shoppingTest;
172 | (testMachine.states.cart as any).meta = cartTest;
173 | (testMachine.states.orderFailed as any).meta = orderFailedTest;
174 | (testMachine.states.placingOrder as any).meta = placingOrderTest;
175 | (testMachine.states.ordered as any).meta = orderedTest;
176 |
177 | const testModel = createModel(testMachine).withEvents(
178 | getEventConfigs() as any
179 | );
180 |
181 | const testPlans = testModel
182 | .getShortestPathPlans({
183 | filter: (state) =>
184 | state.context.ordersCompleted <= 1 &&
185 | state.context.cartsCanceled <= 1 &&
186 | state.context.ordersFailed <= 1,
187 | })
188 | .filter(
189 | (plan) =>
190 | plan.state.context.ordersCompleted === 1 &&
191 | plan.state.context.cartsCanceled === 1
192 | );
193 |
194 | testPlans.forEach((plan) => {
195 | describe(plan.description, () => {
196 | plan.paths.forEach((path) => {
197 | it(path.description, async () => {
198 | const submitOrderMock = jest.fn();
199 |
200 | const shared: Shared = {};
201 |
202 | const setSubmitOrderCallbacks = (
203 | submitOrderCallbacks: PromiseCallbacks
204 | ) => {
205 | shared.submitOrderCallbacks = submitOrderCallbacks;
206 | };
207 |
208 | await path.test({
209 | target: render(
210 |
211 | ),
212 | shared,
213 | setSubmitOrderCallbacks,
214 | submitOrderMock,
215 | } as TestCycleContext);
216 | });
217 | });
218 | });
219 | });
220 |
221 | it("should have full coverage", () => {
222 | return testModel.testCoverage();
223 | });
224 | });
225 | });
226 |
--------------------------------------------------------------------------------
/src/L6/Order.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "../Order.css";
3 | import Button from "@material-ui/core/Button";
4 | import { Machine } from "xstate";
5 | import { useMachine } from "@xstate/react";
6 |
7 | type OrderServices = {
8 | submitOrder: () => Promise;
9 | };
10 |
11 | // Makeshift dependency injection to simplify tutorial
12 | type OrderProps = {
13 | services?: OrderServices;
14 | };
15 |
16 | const getServices = (): OrderServices => ({
17 | // Added service to simulate asynchronous calls to external applications
18 | submitOrder: () => {
19 | const delay = 1000;
20 |
21 | if (Math.random() < 0.5) {
22 | return new Promise((resolve) => setTimeout(resolve, delay));
23 | }
24 | return new Promise((_, reject) =>
25 | setTimeout(() => reject(new Error("Order Failed")), delay)
26 | );
27 | },
28 | });
29 |
30 | const createOrderMachine = (services: OrderServices) =>
31 | Machine(
32 | {
33 | id: "order",
34 | initial: "shopping",
35 | states: {
36 | shopping: { on: { ADD_TO_CART: "cart" } },
37 | cart: { on: { PLACE_ORDER: "placingOrder", CANCEL: "shopping" } },
38 | // Waiting state
39 | placingOrder: {
40 | invoke: {
41 | src: "submitOrder",
42 | onDone: "ordered",
43 | onError: "orderFailed",
44 | },
45 | },
46 | // Failed state
47 | orderFailed: {
48 | on: { PLACE_ORDER: "placingOrder", CANCEL: "shopping" },
49 | },
50 | ordered: { on: { CONTINUE_SHOPPING: "shopping" } },
51 | },
52 | },
53 | {
54 | services: {
55 | ...services,
56 | },
57 | }
58 | );
59 |
60 | const Order: React.FC = ({ services }) => {
61 | const [orderMachineState, send] = useMachine(
62 | createOrderMachine(services ?? getServices())
63 | );
64 |
65 | return (
66 |
67 |
{orderMachineState.value}
68 |
69 | {orderMachineState.value === "shopping" && (
70 |
77 | )}
78 | {(orderMachineState.value === "cart" ||
79 | orderMachineState.value === "orderFailed") && (
80 |
87 | )}
88 | {(orderMachineState.value === "cart" ||
89 | orderMachineState.value === "orderFailed") && (
90 |
93 | )}
94 | {orderMachineState.value === "ordered" && (
95 |
98 | )}
99 |
100 |
101 | );
102 | };
103 |
104 | export default Order;
105 |
--------------------------------------------------------------------------------
/src/Order.css:
--------------------------------------------------------------------------------
1 | .container {
2 | height: 100vh;
3 | width: 100%;
4 | display: flex;
5 | flex-direction: column;
6 | justify-content: center;
7 | align-items: center;
8 | }
9 |
10 | .container div {
11 | margin-bottom: 20px;
12 | }
13 |
14 | .buttons {
15 | flex-direction: row;
16 | }
17 |
18 | .buttons button {
19 | margin-left: 10px;
20 | margin-right: 10px;
21 | }
22 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import * as serviceWorker from './serviceWorker';
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById('root')
12 | );
13 |
14 | // If you want your app to work offline and load faster, you can change
15 | // unregister() to register() below. Note this comes with some pitfalls.
16 | // Learn more about service workers: https://bit.ly/CRA-PWA
17 | serviceWorker.unregister();
18 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { 'Service-Worker': 'script' },
105 | })
106 | .then(response => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get('content-type');
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf('javascript') === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then(registration => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | 'No internet connection found. App is running in offline mode.'
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ('serviceWorker' in navigator) {
133 | navigator.serviceWorker.ready
134 | .then(registration => {
135 | registration.unregister();
136 | })
137 | .catch(error => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react"
17 | },
18 | "include": ["src", "cypress/integration"]
19 | // "types": ["cypress"]
20 | }
21 |
--------------------------------------------------------------------------------