Bläddra i källkod

Write integration tests (#719)

* Scaffold a simple test case for debugging

* Set up Jest environment that works with React

- Install and set up react-testing-library
- "Unignore" roughjs and browser-nativejs transformations
- Separate App component from ReactDOM

* Write first passing test

- Mock canvas
- Remove App file and mount/unmount ReactDOM on import

* Add tests for drag create behavior

* Fix comments in dragCreate

* Pin jest-mock-canvas dependency

* Remove dependency range for testing library

* Add tests for multi point mode and selection element

* Fix all tests due to decrease in updates to canvas when changing tools

* Disable state updates if component is unmounted

- Remove all event listeners
- Disable storing scene in state if component is unmounted

* Add tests for move and element selection

* Merge branch 'master' into add-integration-tests

* Add tests for resizing rectangle

* move unmounted check to syncActionResult method

* Use a custom test renderer instead of default testing-library functions

* Add custom query for selecting tools

* move files around

Co-authored-by: David Luzar <luzar.david@gmail.com>
Gasim Gasimzada 5 år sedan
förälder
incheckning
7183234895

+ 268 - 0
package-lock.json

@@ -1292,6 +1292,12 @@
         "any-observable": "^0.3.0"
       }
     },
+    "@sheerun/mutationobserver-shim": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz",
+      "integrity": "sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q==",
+      "dev": true
+    },
     "@svgr/babel-plugin-add-jsx-attribute": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz",
@@ -1401,6 +1407,189 @@
         "loader-utils": "^1.2.3"
       }
     },
+    "@testing-library/dom": {
+      "version": "6.12.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-6.12.2.tgz",
+      "integrity": "sha512-KCnvHra5fV+wDxg3wJObGvZFxq7v1DJt829GNFLuRDjKxVNc/B5AdsylNF5PMHFbWMXDsHwM26d2NZcZO9KjbQ==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.6.2",
+        "@sheerun/mutationobserver-shim": "^0.3.2",
+        "@types/testing-library__dom": "^6.0.0",
+        "aria-query": "3.0.0",
+        "pretty-format": "^24.9.0",
+        "wait-for-expect": "^3.0.0"
+      }
+    },
+    "@testing-library/jest-dom": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.1.1.tgz",
+      "integrity": "sha512-7xnmBFcUmmUVAUhFiZ/u3CxFh1e46THAwra4SiiKNCW4By26RedCRwEk0rtleFPZG0wlTSNOKDvJjWYy93dp0w==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.8.3",
+        "@types/testing-library__jest-dom": "^5.0.0",
+        "chalk": "^3.0.0",
+        "css": "^2.2.4",
+        "css.escape": "^1.5.1",
+        "jest-diff": "^25.1.0",
+        "jest-matcher-utils": "^25.1.0",
+        "lodash": "^4.17.15",
+        "pretty-format": "^25.1.0",
+        "redent": "^3.0.0"
+      },
+      "dependencies": {
+        "@jest/types": {
+          "version": "25.1.0",
+          "resolved": "https://registry.npmjs.org/@jest/types/-/types-25.1.0.tgz",
+          "integrity": "sha512-VpOtt7tCrgvamWZh1reVsGADujKigBUFTi19mlRjqEGsE8qH4r3s+skY33dNdXOwyZIvuftZ5tqdF1IgsMejMA==",
+          "dev": true,
+          "requires": {
+            "@types/istanbul-lib-coverage": "^2.0.0",
+            "@types/istanbul-reports": "^1.1.1",
+            "@types/yargs": "^15.0.0",
+            "chalk": "^3.0.0"
+          }
+        },
+        "@types/yargs": {
+          "version": "15.0.3",
+          "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.3.tgz",
+          "integrity": "sha512-XCMQRK6kfpNBixHLyHUsGmXrpEmFFxzMrcnSXFMziHd8CoNJo8l16FkHyQq4x+xbM7E2XL83/O78OD8u+iZTdQ==",
+          "dev": true,
+          "requires": {
+            "@types/yargs-parser": "*"
+          }
+        },
+        "ansi-styles": {
+          "version": "4.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
+          "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
+          "dev": true,
+          "requires": {
+            "@types/color-name": "^1.1.1",
+            "color-convert": "^2.0.1"
+          }
+        },
+        "chalk": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
+          "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.1.0",
+            "supports-color": "^7.1.0"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "diff-sequences": {
+          "version": "25.1.0",
+          "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.1.0.tgz",
+          "integrity": "sha512-nFIfVk5B/NStCsJ+zaPO4vYuLjlzQ6uFvPxzYyHlejNZ/UGa7G/n7peOXVrVNvRuyfstt+mZQYGpjxg9Z6N8Kw==",
+          "dev": true
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "jest-diff": {
+          "version": "25.1.0",
+          "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.1.0.tgz",
+          "integrity": "sha512-nepXgajT+h017APJTreSieh4zCqnSHEJ1iT8HDlewu630lSJ4Kjjr9KNzm+kzGwwcpsDE6Snx1GJGzzsefaEHw==",
+          "dev": true,
+          "requires": {
+            "chalk": "^3.0.0",
+            "diff-sequences": "^25.1.0",
+            "jest-get-type": "^25.1.0",
+            "pretty-format": "^25.1.0"
+          }
+        },
+        "jest-get-type": {
+          "version": "25.1.0",
+          "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.1.0.tgz",
+          "integrity": "sha512-yWkBnT+5tMr8ANB6V+OjmrIJufHtCAqI5ic2H40v+tRqxDmE0PGnIiTyvRWFOMtmVHYpwRqyazDbTnhpjsGvLw==",
+          "dev": true
+        },
+        "jest-matcher-utils": {
+          "version": "25.1.0",
+          "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-25.1.0.tgz",
+          "integrity": "sha512-KGOAFcSFbclXIFE7bS4C53iYobKI20ZWleAdAFun4W1Wz1Kkej8Ng6RRbhL8leaEvIOjGXhGf/a1JjO8bkxIWQ==",
+          "dev": true,
+          "requires": {
+            "chalk": "^3.0.0",
+            "jest-diff": "^25.1.0",
+            "jest-get-type": "^25.1.0",
+            "pretty-format": "^25.1.0"
+          }
+        },
+        "pretty-format": {
+          "version": "25.1.0",
+          "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.1.0.tgz",
+          "integrity": "sha512-46zLRSGLd02Rp+Lhad9zzuNZ+swunitn8zIpfD2B4OPCRLXbM87RJT2aBLBWYOznNUML/2l/ReMyWNC80PJBUQ==",
+          "dev": true,
+          "requires": {
+            "@jest/types": "^25.1.0",
+            "ansi-regex": "^5.0.0",
+            "ansi-styles": "^4.0.0",
+            "react-is": "^16.12.0"
+          }
+        },
+        "redent": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+          "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+          "dev": true,
+          "requires": {
+            "indent-string": "^4.0.0",
+            "strip-indent": "^3.0.0"
+          }
+        },
+        "strip-indent": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+          "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+          "dev": true,
+          "requires": {
+            "min-indent": "^1.0.0"
+          }
+        },
+        "supports-color": {
+          "version": "7.1.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
+          "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "@testing-library/react": {
+      "version": "9.4.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-9.4.0.tgz",
+      "integrity": "sha512-XdhDWkI4GktUPsz0AYyeQ8M9qS/JFie06kcSnUVcpgOwFjAu9vhwR83qBl+lw9yZWkbECjL8Hd+n5hH6C0oWqg==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.7.6",
+        "@testing-library/dom": "^6.11.0",
+        "@types/testing-library__react": "^9.1.2"
+      }
+    },
     "@types/babel__core": {
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.3.tgz",
@@ -1668,6 +1857,34 @@
       "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz",
       "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw=="
     },
+    "@types/testing-library__dom": {
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/@types/testing-library__dom/-/testing-library__dom-6.12.0.tgz",
+      "integrity": "sha512-PQ/gzABzc53T68RldZ/sJHKCihtP9ofU8XIgOk+H7tlfoCRdg9mqICio5Fo8j3Z8wo+pOfuDsuPprWsn3YtVmA==",
+      "dev": true,
+      "requires": {
+        "pretty-format": "^24.3.0"
+      }
+    },
+    "@types/testing-library__jest-dom": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.0.1.tgz",
+      "integrity": "sha512-GiPXQBVF9O4DG9cssD2d266vozBJvC5Tnv6aeH5ujgYJgys1DYm9AFCz7YC+STR5ksGxq3zCt+yP8T1wbk2DFg==",
+      "dev": true,
+      "requires": {
+        "@types/jest": "*"
+      }
+    },
+    "@types/testing-library__react": {
+      "version": "9.1.2",
+      "resolved": "https://registry.npmjs.org/@types/testing-library__react/-/testing-library__react-9.1.2.tgz",
+      "integrity": "sha512-CYaMqrswQ+cJACy268jsLAw355DZtPZGt3Jwmmotlcu8O/tkoXBI6AeZ84oZBJsIsesozPKzWzmv/0TIU+1E9Q==",
+      "dev": true,
+      "requires": {
+        "@types/react-dom": "*",
+        "@types/testing-library__dom": "*"
+      }
+    },
     "@types/yargs": {
       "version": "13.0.8",
       "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.8.tgz",
@@ -3863,6 +4080,12 @@
       "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.2.1.tgz",
       "integrity": "sha512-WwOrosiQTvyms+Ti5ZC5vGEK0Vod3FTt1ca+payZqvKuGJF+dq7bG63DstxtN0dpm6FxY27a/zS3Wten+gEtGw=="
     },
+    "css.escape": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+      "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=",
+      "dev": true
+    },
     "cssdb": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-4.4.0.tgz",
@@ -3873,6 +4096,12 @@
       "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
       "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
     },
+    "cssfontparser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz",
+      "integrity": "sha1-9AIvyPlwDGgCnVQghK+69CWj8+M=",
+      "dev": true
+    },
     "cssnano": {
       "version": "4.1.10",
       "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.1.10.tgz",
@@ -7154,6 +7383,16 @@
         }
       }
     },
+    "jest-canvas-mock": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.2.0.tgz",
+      "integrity": "sha512-DcJdchb7eWFZkt6pvyceWWnu3lsp5QWbUeXiKgEMhwB3sMm5qHM1GQhDajvJgBeiYpgKcojbzZ53d/nz6tXvJw==",
+      "dev": true,
+      "requires": {
+        "cssfontparser": "^1.2.1",
+        "parse-color": "^1.0.0"
+      }
+    },
     "jest-changed-files": {
       "version": "24.9.0",
       "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-24.9.0.tgz",
@@ -9390,6 +9629,12 @@
       "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
       "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
     },
+    "min-indent": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz",
+      "integrity": "sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY=",
+      "dev": true
+    },
     "mini-css-extract-plugin": {
       "version": "0.9.0",
       "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz",
@@ -10359,6 +10604,23 @@
         "safe-buffer": "^5.1.1"
       }
     },
+    "parse-color": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/parse-color/-/parse-color-1.0.0.tgz",
+      "integrity": "sha1-e3SLlag/A/FqlPU15S1/PZRlhhk=",
+      "dev": true,
+      "requires": {
+        "color-convert": "~0.5.0"
+      },
+      "dependencies": {
+        "color-convert": {
+          "version": "0.5.3",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz",
+          "integrity": "sha1-vbbGnOZg+t/+CwAHzER+G59ygr0=",
+          "dev": true
+        }
+      }
+    },
     "parse-json": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
@@ -14952,6 +15214,12 @@
         "xml-name-validator": "^3.0.0"
       }
     },
+    "wait-for-expect": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/wait-for-expect/-/wait-for-expect-3.0.1.tgz",
+      "integrity": "sha512-3Ha7lu+zshEG/CeHdcpmQsZnnZpPj/UsG3DuKO8FskjuDbkx3jE3845H+CuwZjA2YWYDfKMU2KhnCaXMLd3wVw==",
+      "dev": true
+    },
     "walker": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz",

+ 8 - 0
package.json

@@ -16,6 +16,8 @@
   },
   "description": "",
   "devDependencies": {
+    "@testing-library/jest-dom": "5.1.1",
+    "@testing-library/react": "9.4.0",
     "@types/jest": "25.1.2",
     "@types/nanoid": "2.1.0",
     "@types/react": "16.9.19",
@@ -24,12 +26,18 @@
     "eslint-config-prettier": "6.10.0",
     "eslint-plugin-prettier": "3.1.2",
     "husky": "4.2.1",
+    "jest-canvas-mock": "2.2.0",
     "lint-staged": "10.0.7",
     "node-sass": "4.13.1",
     "prettier": "1.19.1",
     "rewire": "4.0.1",
     "typescript": "3.7.5"
   },
+  "jest": {
+    "transformIgnorePatterns": [
+      "node_modules/(?!(roughjs|browser-nativefs)/)"
+    ]
+  },
   "eslintConfig": {
     "extends": [
       "prettier",

+ 20 - 5
src/index.tsx

@@ -475,6 +475,9 @@ export class App extends React.Component<any, AppState> {
     res: ActionResult,
     commitToHistory: boolean = true,
   ) => {
+    if (this.unmounted) {
+      return;
+    }
     if (res.elements) {
       elements = res.elements;
       if (commitToHistory) {
@@ -515,6 +518,11 @@ export class App extends React.Component<any, AppState> {
     this.saveDebounced.flush();
   };
 
+  private disableEvent: EventHandlerNonNull = e => {
+    e.preventDefault();
+  };
+
+  private unmounted = false;
   public async componentDidMount() {
     document.addEventListener("copy", this.onCopy);
     document.addEventListener("paste", this.pasteFromClipboard);
@@ -526,28 +534,32 @@ export class App extends React.Component<any, AppState> {
     window.addEventListener("resize", this.onResize, false);
     window.addEventListener("unload", this.onUnload, false);
     window.addEventListener("blur", this.onUnload, false);
-    window.addEventListener("dragover", e => e.preventDefault(), false);
-    window.addEventListener("drop", e => e.preventDefault(), false);
+    window.addEventListener("dragover", this.disableEvent, false);
+    window.addEventListener("drop", this.disableEvent, false);
 
     const searchParams = new URLSearchParams(window.location.search);
     const id = searchParams.get("id");
 
     if (id) {
       // Backwards compatibility with legacy url format
-      this.syncActionResult(await loadScene(id));
+      const scene = await loadScene(id);
+      this.syncActionResult(scene);
     } else {
       const match = window.location.hash.match(
         /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
       );
       if (match) {
-        this.syncActionResult(await loadScene(match[1], match[2]));
+        const scene = await loadScene(match[1], match[2]);
+        this.syncActionResult(scene);
       } else {
-        this.syncActionResult(await loadScene(null));
+        const scene = await loadScene(null);
+        this.syncActionResult(scene);
       }
     }
   }
 
   public componentWillUnmount() {
+    this.unmounted = true;
     document.removeEventListener("copy", this.onCopy);
     document.removeEventListener("paste", this.pasteFromClipboard);
     document.removeEventListener("cut", this.onCut);
@@ -558,9 +570,12 @@ export class App extends React.Component<any, AppState> {
       this.updateCurrentCursorPosition,
       false,
     );
+    document.removeEventListener("keyup", this.onKeyUp);
     window.removeEventListener("resize", this.onResize, false);
     window.removeEventListener("unload", this.onUnload, false);
     window.removeEventListener("blur", this.onUnload, false);
+    window.removeEventListener("dragover", this.disableEvent, false);
+    window.removeEventListener("drop", this.disableEvent, false);
   }
 
   public state: AppState = getDefaultAppState();

+ 8 - 0
src/setupTests.ts

@@ -0,0 +1,8 @@
+import "@testing-library/jest-dom";
+import "jest-canvas-mock";
+
+// ReactDOM is located inside index.tsx file
+// as a result, we need a place for it to render into
+const element = document.createElement("div");
+element.id = "root";
+document.body.appendChild(element);

+ 277 - 0
src/tests/dragCreate.test.tsx

@@ -0,0 +1,277 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { App } from "../index";
+import * as Renderer from "../renderer/renderScene";
+import { KEYS } from "../keys";
+import { render, fireEvent } from "./test-utils";
+
+// Unmount ReactDOM from root
+ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+
+const renderScene = jest.spyOn(Renderer, "renderScene");
+beforeEach(() => {
+  localStorage.clear();
+  renderScene.mockClear();
+});
+
+describe("add element to the scene when mouse dragging long enough", () => {
+  it("rectangle", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("rectangle");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+
+    // start from (30, 20)
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+
+    // move to (60,70)
+    fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+
+    // finish (position does not matter)
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene.mock.calls[3][1]).toBeNull();
+    const elements = renderScene.mock.calls[3][0];
+
+    expect(elements.length).toEqual(1);
+    expect(elements[0].type).toEqual("rectangle");
+    expect(elements[0].x).toEqual(30);
+    expect(elements[0].y).toEqual(20);
+    expect(elements[0].width).toEqual(30); // 60 - 30
+    expect(elements[0].height).toEqual(50); // 70 - 20
+  });
+
+  it("ellipse", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("ellipse");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+
+    // start from (30, 20)
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+
+    // move to (60,70)
+    fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+
+    // finish (position does not matter)
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene.mock.calls[3][1]).toBeNull();
+
+    const elements = renderScene.mock.calls[3][0];
+    expect(elements.length).toEqual(1);
+    expect(elements[0].type).toEqual("ellipse");
+    expect(elements[0].x).toEqual(30);
+    expect(elements[0].y).toEqual(20);
+    expect(elements[0].width).toEqual(30); // 60 - 30
+    expect(elements[0].height).toEqual(50); // 70 - 20
+  });
+
+  it("diamond", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("diamond");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+
+    // start from (30, 20)
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+
+    // move to (60,70)
+    fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+
+    // finish (position does not matter)
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene.mock.calls[3][1]).toBeNull();
+
+    const elements = renderScene.mock.calls[3][0];
+    expect(elements.length).toEqual(1);
+    expect(elements[0].type).toEqual("diamond");
+    expect(elements[0].x).toEqual(30);
+    expect(elements[0].y).toEqual(20);
+    expect(elements[0].width).toEqual(30); // 60 - 30
+    expect(elements[0].height).toEqual(50); // 70 - 20
+  });
+
+  it("arrow", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("arrow");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+
+    // start from (30, 20)
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+
+    // move to (60,70)
+    fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+
+    // finish (position does not matter)
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene.mock.calls[3][1]).toBeNull();
+    const elements = renderScene.mock.calls[3][0];
+
+    expect(elements.length).toEqual(1);
+    expect(elements[0].type).toEqual("arrow");
+    expect(elements[0].x).toEqual(30);
+    expect(elements[0].y).toEqual(20);
+    expect(elements[0].points.length).toEqual(2);
+    expect(elements[0].points[0]).toEqual([0, 0]);
+    expect(elements[0].points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20)
+  });
+
+  it("line", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("line");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+
+    // start from (30, 20)
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+
+    // move to (60,70)
+    fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+
+    // finish (position does not matter)
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene.mock.calls[3][1]).toBeNull();
+    const elements = renderScene.mock.calls[3][0];
+
+    expect(elements.length).toEqual(1);
+    expect(elements[0].type).toEqual("line");
+    expect(elements[0].x).toEqual(30);
+    expect(elements[0].y).toEqual(20);
+    expect(elements[0].points.length).toEqual(2);
+    expect(elements[0].points[0]).toEqual([0, 0]);
+    expect(elements[0].points[1]).toEqual([30, 50]); // (60 - 30, 70 - 20)
+  });
+});
+
+describe("do not add element to the scene if size is too small", () => {
+  it("rectangle", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("rectangle");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+
+    // start from (30, 20)
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+
+    // finish (position does not matter)
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene.mock.calls[2][1]).toBeNull();
+    const elements = renderScene.mock.calls[2][0];
+
+    expect(elements.length).toEqual(0);
+  });
+
+  it("ellipse", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("ellipse");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+
+    // start from (30, 20)
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+
+    // finish (position does not matter)
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene.mock.calls[2][1]).toBeNull();
+    const elements = renderScene.mock.calls[2][0];
+
+    expect(elements.length).toEqual(0);
+  });
+
+  it("diamond", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("diamond");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+
+    // start from (30, 20)
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+
+    // finish (position does not matter)
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene.mock.calls[2][1]).toBeNull();
+    const elements = renderScene.mock.calls[2][0];
+
+    expect(elements.length).toEqual(0);
+  });
+
+  it("arrow", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("arrow");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+
+    // start from (30, 20)
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+
+    // finish (position does not matter)
+    fireEvent.mouseUp(canvas);
+
+    // we need to finalize it because arrows and lines enter multi-mode
+    fireEvent.keyDown(document, { key: KEYS.ENTER });
+
+    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene.mock.calls[3][1]).toBeNull();
+    const elements = renderScene.mock.calls[3][0];
+
+    expect(elements.length).toEqual(0);
+  });
+
+  it("line", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("line");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+
+    // start from (30, 20)
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+
+    // finish (position does not matter)
+    fireEvent.mouseUp(canvas);
+
+    // we need to finalize it because arrows and lines enter multi-mode
+    fireEvent.keyDown(document, { key: KEYS.ENTER });
+
+    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene.mock.calls[3][1]).toBeNull();
+    const elements = renderScene.mock.calls[3][0];
+
+    expect(elements.length).toEqual(0);
+  });
+});

+ 88 - 0
src/tests/move.test.tsx

@@ -0,0 +1,88 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { render, fireEvent } from "./test-utils";
+import { App } from "../index";
+import * as Renderer from "../renderer/renderScene";
+
+// Unmount ReactDOM from root
+ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+
+const renderScene = jest.spyOn(Renderer, "renderScene");
+beforeEach(() => {
+  localStorage.clear();
+  renderScene.mockClear();
+});
+
+describe("move element", () => {
+  it("rectangle", () => {
+    const { getByToolName, container } = render(<App />);
+    const canvas = container.querySelector("canvas")!;
+
+    {
+      // create element
+      const tool = getByToolName("rectangle");
+      fireEvent.click(tool);
+      fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+      fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+      fireEvent.mouseUp(canvas);
+
+      expect(renderScene).toHaveBeenCalledTimes(4);
+      const elements = renderScene.mock.calls[3][0];
+      const selectionElement = renderScene.mock.calls[3][1];
+      expect(selectionElement).toBeNull();
+      expect(elements.length).toEqual(1);
+      expect(elements[0].isSelected).toBeTruthy();
+      expect([elements[0].x, elements[0].y]).toEqual([30, 20]);
+
+      renderScene.mockClear();
+    }
+
+    fireEvent.mouseDown(canvas, { clientX: 50, clientY: 20 });
+    fireEvent.mouseMove(canvas, { clientX: 20, clientY: 40 });
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(3);
+    const elements = renderScene.mock.calls[2][0];
+    expect(renderScene.mock.calls[2][1]).toBeNull();
+    expect(elements.length).toEqual(1);
+    expect([elements[0].x, elements[0].y]).toEqual([0, 40]);
+  });
+});
+
+describe("duplicate element on move when ALT is clicked", () => {
+  it("rectangle", () => {
+    const { getByToolName, container } = render(<App />);
+    const canvas = container.querySelector("canvas")!;
+
+    {
+      // create element
+      const tool = getByToolName("rectangle");
+      fireEvent.click(tool);
+      fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+      fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+      fireEvent.mouseUp(canvas);
+
+      expect(renderScene).toHaveBeenCalledTimes(4);
+      const elements = renderScene.mock.calls[3][0];
+      const selectionElement = renderScene.mock.calls[3][1];
+      expect(selectionElement).toBeNull();
+      expect(elements.length).toEqual(1);
+      expect(elements[0].isSelected).toBeTruthy();
+      expect([elements[0].x, elements[0].y]).toEqual([30, 20]);
+
+      renderScene.mockClear();
+    }
+
+    fireEvent.mouseDown(canvas, { clientX: 50, clientY: 20, altKey: true });
+    fireEvent.mouseMove(canvas, { clientX: 20, clientY: 40 });
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(3);
+    const elements = renderScene.mock.calls[2][0];
+    expect(renderScene.mock.calls[2][1]).toBeNull();
+    expect(elements.length).toEqual(2);
+    // previous element should stay intact
+    expect([elements[0].x, elements[0].y]).toEqual([30, 20]);
+    expect([elements[1].x, elements[1].y]).toEqual([0, 40]);
+  });
+});

+ 140 - 0
src/tests/multiPointCreate.test.tsx

@@ -0,0 +1,140 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { render, fireEvent } from "./test-utils";
+import { App } from "../index";
+import * as Renderer from "../renderer/renderScene";
+import { KEYS } from "../keys";
+
+// Unmount ReactDOM from root
+ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+
+const renderScene = jest.spyOn(Renderer, "renderScene");
+beforeEach(() => {
+  localStorage.clear();
+  renderScene.mockClear();
+});
+
+describe("remove shape in non linear elements", () => {
+  it("rectangle", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("rectangle");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+    fireEvent.mouseUp(canvas, { clientX: 30, clientY: 30 });
+
+    expect(renderScene).toHaveBeenCalledTimes(3);
+    const elements = renderScene.mock.calls[2][0];
+    expect(elements.length).toEqual(0);
+  });
+
+  it("ellipse", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("ellipse");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+    fireEvent.mouseUp(canvas, { clientX: 30, clientY: 30 });
+
+    expect(renderScene).toHaveBeenCalledTimes(3);
+    const elements = renderScene.mock.calls[2][0];
+    expect(elements.length).toEqual(0);
+  });
+
+  it("diamond", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("diamond");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+    fireEvent.mouseUp(canvas, { clientX: 30, clientY: 30 });
+
+    expect(renderScene).toHaveBeenCalledTimes(3);
+    const elements = renderScene.mock.calls[2][0];
+    expect(elements.length).toEqual(0);
+  });
+});
+
+describe("multi point mode in linear elements", () => {
+  it("arrow", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("arrow");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+    // first point is added on mouse down
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 30 });
+
+    // second point, enable multi point
+    fireEvent.mouseUp(canvas, { clientX: 30, clientY: 30 });
+    fireEvent.mouseMove(canvas, { clientX: 50, clientY: 60 });
+
+    // third point
+    fireEvent.mouseDown(canvas, { clientX: 50, clientY: 60 });
+    fireEvent.mouseUp(canvas);
+    fireEvent.mouseMove(canvas, { clientX: 100, clientY: 140 });
+
+    // done
+    fireEvent.mouseDown(canvas);
+    fireEvent.mouseUp(canvas);
+    fireEvent.keyDown(document, { key: KEYS.ENTER });
+
+    expect(renderScene).toHaveBeenCalledTimes(8);
+    const elements = renderScene.mock.calls[7][0];
+    expect(elements.length).toEqual(1);
+
+    expect(elements[0].type).toEqual("arrow");
+    expect(elements[0].x).toEqual(30);
+    expect(elements[0].y).toEqual(30);
+    expect(elements[0].points).toEqual([
+      [0, 0],
+      [20, 30],
+      [70, 110],
+    ]);
+  });
+
+  it("line", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("line");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+    // first point is added on mouse down
+    fireEvent.mouseDown(canvas, { clientX: 30, clientY: 30 });
+
+    // second point, enable multi point
+    fireEvent.mouseUp(canvas, { clientX: 30, clientY: 30 });
+    fireEvent.mouseMove(canvas, { clientX: 50, clientY: 60 });
+
+    // third point
+    fireEvent.mouseDown(canvas, { clientX: 50, clientY: 60 });
+    fireEvent.mouseUp(canvas);
+    fireEvent.mouseMove(canvas, { clientX: 100, clientY: 140 });
+
+    // done
+    fireEvent.mouseDown(canvas);
+    fireEvent.mouseUp(canvas);
+    fireEvent.keyDown(document, { key: KEYS.ENTER });
+
+    expect(renderScene).toHaveBeenCalledTimes(8);
+    const elements = renderScene.mock.calls[7][0];
+    expect(elements.length).toEqual(1);
+
+    expect(elements[0].type).toEqual("line");
+    expect(elements[0].x).toEqual(30);
+    expect(elements[0].y).toEqual(30);
+    expect(elements[0].points).toEqual([
+      [0, 0],
+      [20, 30],
+      [70, 110],
+    ]);
+  });
+});

+ 32 - 0
src/tests/queries/toolQueries.ts

@@ -0,0 +1,32 @@
+import { queries, buildQueries } from "@testing-library/react";
+
+const _getAllByToolName = (container: HTMLElement, tool: string) => {
+  const toolMap: { [propKey: string]: string } = {
+    selection: "Selection — S, 1",
+    rectangle: "Rectangle — R, 2",
+    diamond: "Diamond — D, 3",
+    ellipse: "Ellipse — E, 4",
+    arrow: "Arrow — A, 5",
+    line: "Line — L, 6",
+  };
+
+  const toolTitle = toolMap[tool as string];
+  return queries.getAllByTitle(container, toolTitle);
+};
+
+const getMultipleError = (c: any, tool: any) =>
+  `Found multiple elements with tool name: ${tool}`;
+const getMissingError = (c: any, tool: any) =>
+  `Unable to find an element with tool name: ${tool}`;
+
+export const [
+  queryByToolName,
+  getAllByToolName,
+  getByToolName,
+  findAllByToolName,
+  findByToolName,
+] = buildQueries<string[]>(
+  _getAllByToolName,
+  getMultipleError,
+  getMissingError,
+);

+ 98 - 0
src/tests/resize.test.tsx

@@ -0,0 +1,98 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { render, fireEvent } from "./test-utils";
+import { App } from "../index";
+import * as Renderer from "../renderer/renderScene";
+
+// Unmount ReactDOM from root
+ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+
+const renderScene = jest.spyOn(Renderer, "renderScene");
+beforeEach(() => {
+  localStorage.clear();
+  renderScene.mockClear();
+});
+
+describe("resize element", () => {
+  it("rectangle", () => {
+    const { getByToolName, container } = render(<App />);
+    const canvas = container.querySelector("canvas")!;
+
+    {
+      // create element
+      const tool = getByToolName("rectangle");
+      fireEvent.click(tool);
+      fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+      fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+      fireEvent.mouseUp(canvas);
+
+      expect(renderScene).toHaveBeenCalledTimes(4);
+      const elements = renderScene.mock.calls[3][0];
+      const selectionElement = renderScene.mock.calls[3][1];
+      expect(selectionElement).toBeNull();
+      expect(elements.length).toEqual(1);
+      expect(elements[0].isSelected).toBeTruthy();
+      expect([elements[0].x, elements[0].y]).toEqual([30, 20]);
+
+      renderScene.mockClear();
+    }
+
+    // select the element first
+    fireEvent.mouseDown(canvas, { clientX: 50, clientY: 20 });
+    fireEvent.mouseUp(canvas);
+
+    // select a handler rectangle (top-left)
+    fireEvent.mouseDown(canvas, { clientX: 21, clientY: 13 });
+    fireEvent.mouseMove(canvas, { clientX: 20, clientY: 40 });
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(5);
+    const elements = renderScene.mock.calls[4][0];
+    expect(renderScene.mock.calls[4][1]).toBeNull();
+    expect(elements.length).toEqual(1);
+    expect([elements[0].x, elements[0].y]).toEqual([29, 47]);
+    expect([elements[0].width, elements[0].height]).toEqual([31, 23]);
+  });
+});
+
+describe("resize element with aspect ratio when SHIFT is clicked", () => {
+  it("rectangle", () => {
+    const { getByToolName, container } = render(<App />);
+    const canvas = container.querySelector("canvas")!;
+
+    {
+      // create element
+      const tool = getByToolName("rectangle");
+      fireEvent.click(tool);
+      fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+      fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+      fireEvent.mouseUp(canvas);
+
+      expect(renderScene).toHaveBeenCalledTimes(4);
+      const elements = renderScene.mock.calls[3][0];
+      const selectionElement = renderScene.mock.calls[3][1];
+      expect(selectionElement).toBeNull();
+      expect(elements.length).toEqual(1);
+      expect(elements[0].isSelected).toBeTruthy();
+      expect([elements[0].x, elements[0].y]).toEqual([30, 20]);
+
+      renderScene.mockClear();
+    }
+
+    // select the element first
+    fireEvent.mouseDown(canvas, { clientX: 50, clientY: 20 });
+    fireEvent.mouseUp(canvas);
+
+    // select a handler rectangle (top-left)
+    fireEvent.mouseDown(canvas, { clientX: 21, clientY: 13 });
+    fireEvent.mouseMove(canvas, { clientX: 20, clientY: 40, shiftKey: true });
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(5);
+    const elements = renderScene.mock.calls[4][0];
+    expect(renderScene.mock.calls[4][1]).toBeNull();
+    expect(elements.length).toEqual(1);
+    expect([elements[0].x, elements[0].y]).toEqual([29, 39]);
+    expect([elements[0].width, elements[0].height]).toEqual([31, 31]);
+  });
+});

+ 211 - 0
src/tests/selection.test.tsx

@@ -0,0 +1,211 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { render, fireEvent } from "./test-utils";
+import { App } from "../index";
+import * as Renderer from "../renderer/renderScene";
+import { KEYS } from "../keys";
+
+// Unmount ReactDOM from root
+ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+
+const renderScene = jest.spyOn(Renderer, "renderScene");
+beforeEach(() => {
+  localStorage.clear();
+  renderScene.mockClear();
+});
+
+describe("selection element", () => {
+  it("create selection element on mouse down", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("selection");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+    fireEvent.mouseDown(canvas, { clientX: 60, clientY: 100 });
+
+    expect(renderScene).toHaveBeenCalledTimes(1);
+    const selectionElement = renderScene.mock.calls[0][1]!;
+    expect(selectionElement).not.toBeNull();
+    expect(selectionElement.type).toEqual("selection");
+    expect([selectionElement.x, selectionElement.y]).toEqual([60, 100]);
+    expect([selectionElement.width, selectionElement.height]).toEqual([0, 0]);
+
+    // TODO: There is a memory leak if mouse up is not triggered
+    fireEvent.mouseUp(canvas);
+  });
+
+  it("resize selection element on mouse move", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("selection");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+    fireEvent.mouseDown(canvas, { clientX: 60, clientY: 100 });
+    fireEvent.mouseMove(canvas, { clientX: 150, clientY: 30 });
+
+    expect(renderScene).toHaveBeenCalledTimes(2);
+    const selectionElement = renderScene.mock.calls[1][1]!;
+    expect(selectionElement).not.toBeNull();
+    expect(selectionElement.type).toEqual("selection");
+    expect([selectionElement.x, selectionElement.y]).toEqual([60, 30]);
+    expect([selectionElement.width, selectionElement.height]).toEqual([90, 70]);
+
+    // TODO: There is a memory leak if mouse up is not triggered
+    fireEvent.mouseUp(canvas);
+  });
+
+  it("remove selection element on mouse up", () => {
+    const { getByToolName, container } = render(<App />);
+    // select tool
+    const tool = getByToolName("selection");
+    fireEvent.click(tool);
+
+    const canvas = container.querySelector("canvas")!;
+    fireEvent.mouseDown(canvas, { clientX: 60, clientY: 100 });
+    fireEvent.mouseMove(canvas, { clientX: 150, clientY: 30 });
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(3);
+    const selectionElement = renderScene.mock.calls[2][1];
+    expect(selectionElement).toBeNull();
+  });
+});
+
+describe("select single element on the scene", () => {
+  it("rectangle", () => {
+    const { getByToolName, container } = render(<App />);
+    const canvas = container.querySelector("canvas")!;
+    {
+      // create element
+      const tool = getByToolName("rectangle");
+      fireEvent.click(tool);
+      fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+      fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+      fireEvent.mouseUp(canvas);
+      fireEvent.keyDown(document, { key: KEYS.ESCAPE });
+    }
+
+    const tool = getByToolName("selection");
+    fireEvent.click(tool);
+    // click on a line on the rectangle
+    fireEvent.mouseDown(canvas, { clientX: 45, clientY: 20 });
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(7);
+    const elements = renderScene.mock.calls[6][0];
+    const selectionElement = renderScene.mock.calls[6][1];
+    expect(selectionElement).toBeNull();
+    expect(elements.length).toEqual(1);
+    expect(elements[0].isSelected).toBeTruthy();
+  });
+
+  it("diamond", () => {
+    const { getByToolName, container } = render(<App />);
+    const canvas = container.querySelector("canvas")!;
+    {
+      // create element
+      const tool = getByToolName("diamond");
+      fireEvent.click(tool);
+      fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+      fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+      fireEvent.mouseUp(canvas);
+      fireEvent.keyDown(document, { key: KEYS.ESCAPE });
+    }
+
+    const tool = getByToolName("selection");
+    fireEvent.click(tool);
+    // click on a line on the rectangle
+    fireEvent.mouseDown(canvas, { clientX: 45, clientY: 20 });
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(7);
+    const elements = renderScene.mock.calls[6][0];
+    const selectionElement = renderScene.mock.calls[6][1];
+    expect(selectionElement).toBeNull();
+    expect(elements.length).toEqual(1);
+    expect(elements[0].isSelected).toBeTruthy();
+  });
+
+  it("ellipse", () => {
+    const { getByToolName, container } = render(<App />);
+    const canvas = container.querySelector("canvas")!;
+    {
+      // create element
+      const tool = getByToolName("ellipse");
+      fireEvent.click(tool);
+      fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+      fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+      fireEvent.mouseUp(canvas);
+      fireEvent.keyDown(document, { key: KEYS.ESCAPE });
+    }
+
+    const tool = getByToolName("selection");
+    fireEvent.click(tool);
+    // click on a line on the rectangle
+    fireEvent.mouseDown(canvas, { clientX: 45, clientY: 20 });
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(7);
+    const elements = renderScene.mock.calls[6][0];
+    const selectionElement = renderScene.mock.calls[6][1];
+    expect(selectionElement).toBeNull();
+    expect(elements.length).toEqual(1);
+    expect(elements[0].isSelected).toBeTruthy();
+  });
+
+  it("arrow", () => {
+    const { getByToolName, container } = render(<App />);
+    const canvas = container.querySelector("canvas")!;
+    {
+      // create element
+      const tool = getByToolName("arrow");
+      fireEvent.click(tool);
+      fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+      fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+      fireEvent.mouseUp(canvas);
+      fireEvent.keyDown(document, { key: KEYS.ESCAPE });
+    }
+
+    const tool = getByToolName("selection");
+    fireEvent.click(tool);
+    // click on a line on the rectangle
+    fireEvent.mouseDown(canvas, { clientX: 45, clientY: 20 });
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(7);
+    const elements = renderScene.mock.calls[6][0];
+    const selectionElement = renderScene.mock.calls[6][1];
+    expect(selectionElement).toBeNull();
+    expect(elements.length).toEqual(1);
+    expect(elements[0].isSelected).toBeTruthy();
+  });
+
+  it("arrow", () => {
+    const { getByToolName, container } = render(<App />);
+    const canvas = container.querySelector("canvas")!;
+    {
+      // create element
+      const tool = getByToolName("line");
+      fireEvent.click(tool);
+      fireEvent.mouseDown(canvas, { clientX: 30, clientY: 20 });
+      fireEvent.mouseMove(canvas, { clientX: 60, clientY: 70 });
+      fireEvent.mouseUp(canvas);
+      fireEvent.keyDown(document, { key: KEYS.ESCAPE });
+    }
+
+    const tool = getByToolName("selection");
+    fireEvent.click(tool);
+    // click on a line on the rectangle
+    fireEvent.mouseDown(canvas, { clientX: 45, clientY: 20 });
+    fireEvent.mouseUp(canvas);
+
+    expect(renderScene).toHaveBeenCalledTimes(7);
+    const elements = renderScene.mock.calls[6][0];
+    const selectionElement = renderScene.mock.calls[6][1];
+    expect(selectionElement).toBeNull();
+    expect(elements.length).toEqual(1);
+    expect(elements[0].isSelected).toBeTruthy();
+  });
+});

+ 30 - 0
src/tests/test-utils.ts

@@ -0,0 +1,30 @@
+import {
+  render,
+  queries,
+  RenderResult,
+  RenderOptions,
+} from "@testing-library/react";
+
+import * as toolQueries from "./queries/toolQueries";
+
+const customQueries = {
+  ...queries,
+  ...toolQueries,
+};
+
+type TestRenderFn = (
+  ui: React.ReactElement,
+  options?: Omit<RenderOptions, "queries">,
+) => RenderResult<typeof customQueries>;
+
+const renderApp: TestRenderFn = (ui, options) =>
+  render(ui, {
+    queries: customQueries,
+    ...options,
+  });
+
+// re-export everything
+export * from "@testing-library/react";
+
+// override render method
+export { renderApp as render };