├── .eslintrc.json
├── .gitignore
├── .husky
├── pre-commit
└── pre-push
├── .lintstagedrc.json
├── .prettierrc.json
├── README.md
├── package-lock.json
├── package.json
├── src
├── core.js
├── intro.js
├── libs
│ ├── analytics.js
│ ├── currency.js
│ ├── email.js
│ ├── payment.js
│ ├── security.js
│ └── shipping.js
├── main.ts
└── mocking.js
├── tests
├── core.test.js
├── intro.test.js
├── main.test.ts
└── mocking.test.js
├── tsconfig.json
└── vitest.config.js
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
7 | "ignorePatterns": ["dist/"],
8 | "parser": "@typescript-eslint/parser",
9 | "parserOptions": {
10 | "ecmaVersion": "latest",
11 | "sourceType": "module"
12 | },
13 | "plugins": ["@typescript-eslint"],
14 | "root": true,
15 | "rules": {
16 | "space-before-function-paren": "off",
17 | "semi": ["error", "always"]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | coverage/
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/.husky/pre-push:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx vitest run
5 |
--------------------------------------------------------------------------------
/.lintstagedrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "*.+(js|ts)": [
3 | "prettier --write",
4 | "eslint"
5 | ]
6 | }
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Unit Testing JavaScript Code
2 |
3 | This repository contains all of the examples and exercises for my JavaScript testing course.
4 |
5 | - Understand the fundamentals of unit testing and its significance in JavaScript development.
6 | - Master the setup and usage of Vitest for effective JavaScript testing.
7 | - Discover the best practices for writing clean, maintainable, and trustworthy tests.
8 | - Learn various techniques to run and debug tests effectively.
9 | - Explore VSCode shortcuts to boost coding productivity.
10 | - Master working with matchers and crafting precise, effective assertions.
11 | - Practice positive, negative, and boundary testing to cover a wide range of test scenarios.
12 | - Break dependencies in your tests with mocks.
13 | - Improve code quality with static analysis, including TypeScript, ESLint, and Prettier.
14 | - Automate code quality checks with Husky to maintain high coding standards.
15 |
16 | You can find the full course at:
17 |
18 | https://codewithmosh.com
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "javascript-testing",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview",
10 | "test": "vitest",
11 | "test:ui": "vitest --ui",
12 | "coverage": "vitest run --coverage",
13 | "format": "prettier . --write",
14 | "lint": "eslint . --fix",
15 | "check-types": "tsc",
16 | "prepare": "husky install"
17 | },
18 | "devDependencies": {
19 | "@typescript-eslint/eslint-plugin": "^6.19.1",
20 | "@typescript-eslint/parser": "^6.19.1",
21 | "eslint": "^8.56.0",
22 | "eslint-config-standard": "^17.1.0",
23 | "eslint-plugin-import": "^2.29.1",
24 | "eslint-plugin-n": "^16.6.2",
25 | "eslint-plugin-promise": "^6.1.1",
26 | "husky": "^8.0.0",
27 | "lint-staged": "^15.2.0",
28 | "prettier": "^3.2.4",
29 | "typescript": "^5.3.3",
30 | "vite": "^5.0.0",
31 | "vitest": "^1.1.3"
32 | },
33 | "dependencies": {
34 | "delay": "^6.0.0"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/core.js:
--------------------------------------------------------------------------------
1 | // Exercise: Writing good assertions
2 | export function getCoupons() {
3 | return [
4 | { code: 'SAVE20NOW', discount: 0.2 },
5 | { code: 'DISCOUNT50OFF', discount: 0.5 }
6 | ];
7 | }
8 |
9 | // Lesson: Positive and negative testing
10 | export function calculateDiscount(price, discountCode) {
11 | if (typeof price !== 'number' || price <= 0) {
12 | return 'Invalid price';
13 | }
14 |
15 | if (typeof discountCode !== 'string') {
16 | return 'Invalid discount code';
17 | }
18 |
19 | let discount = 0;
20 | if (discountCode === 'SAVE10') {
21 | discount = 0.1;
22 | } else if (discountCode === 'SAVE20') {
23 | discount = 0.2;
24 | }
25 |
26 | return price - price * discount;
27 | }
28 |
29 | // Exercise: Positive and negative testing
30 | export function validateUserInput(username, age) {
31 | const errors = [];
32 |
33 | if (
34 | typeof username !== 'string' ||
35 | username.length < 3 ||
36 | username.length > 255
37 | ) {
38 | errors.push('Invalid username');
39 | }
40 |
41 | if (typeof age !== 'number' || age < 18 || age > 100) {
42 | errors.push('Invalid age');
43 | }
44 |
45 | return errors.length === 0 ? 'Validation successful' : errors.join(', ');
46 | }
47 |
48 | // Lesson: Boundary testing
49 | export function isPriceInRange(price, min, max) {
50 | return price >= min && price <= max;
51 | }
52 |
53 | // Exercise: Boundary testing
54 | export function isValidUsername(username) {
55 | const minLength = 5;
56 | const maxLength = 15;
57 |
58 | if (!username) return false;
59 |
60 | return username.length >= minLength && username.length <= maxLength;
61 | }
62 |
63 | // Exercise: Boundary testing
64 | export function canDrive(age, countryCode) {
65 | const legalDrivingAge = {
66 | US: 16,
67 | UK: 17
68 | };
69 |
70 | if (!legalDrivingAge[countryCode]) {
71 | return 'Invalid country code';
72 | }
73 |
74 | return age >= legalDrivingAge[countryCode];
75 | }
76 |
77 | // Lesson: Testing asynchronous code
78 | export function fetchData() {
79 | // eslint-disable-next-line prefer-promise-reject-errors
80 | return Promise.reject({ reason: 'Operation failed' });
81 | }
82 |
83 | // Lesson: Setup and teardown
84 | export class Stack {
85 | constructor() {
86 | this.items = [];
87 | }
88 |
89 | push(item) {
90 | this.items.push(item);
91 | }
92 |
93 | pop() {
94 | if (this.isEmpty()) {
95 | throw new Error('Stack is empty');
96 | }
97 | return this.items.pop();
98 | }
99 |
100 | peek() {
101 | if (this.isEmpty()) {
102 | throw new Error('Stack is empty');
103 | }
104 | return this.items[this.items.length - 1];
105 | }
106 |
107 | isEmpty() {
108 | return this.items.length === 0;
109 | }
110 |
111 | size() {
112 | return this.items.length;
113 | }
114 |
115 | clear() {
116 | this.items = [];
117 | }
118 | }
119 |
120 | // Additional exercises
121 | export function createProduct(product) {
122 | if (!product.name) {
123 | return {
124 | success: false,
125 | error: { code: 'invalid_name', message: 'Name is missing' }
126 | };
127 | }
128 |
129 | if (product.price <= 0) {
130 | return {
131 | success: false,
132 | error: { code: 'invalid_price', message: 'Price is missing' }
133 | };
134 | }
135 |
136 | return { success: true, message: 'Product was successfully published' };
137 | }
138 |
139 | export function isStrongPassword(password) {
140 | // Check the length of the password (minimum 8 characters)
141 | if (password.length < 8) {
142 | return false;
143 | }
144 |
145 | // Check if the password contains at least one uppercase letter
146 | if (!/[A-Z]/.test(password)) {
147 | return false;
148 | }
149 |
150 | // Check if the password contains at least one lowercase letter
151 | if (!/[a-z]/.test(password)) {
152 | return false;
153 | }
154 |
155 | // Check if the password contains at least one digit (number)
156 | if (!/\d/.test(password)) {
157 | return false;
158 | }
159 |
160 | // If all criteria are met, consider the password strong
161 | return true;
162 | }
163 |
--------------------------------------------------------------------------------
/src/intro.js:
--------------------------------------------------------------------------------
1 | // Lesson: Writing your first tests
2 | export function max(a, b) {
3 | return a > b ? a : b;
4 | }
5 |
6 | // Exercise
7 | export function fizzBuzz(n) {
8 | if (n % 3 === 0 && n % 5 === 0) return 'FizzBuzz';
9 | if (n % 3 === 0) return 'Fizz';
10 | if (n % 5 === 0) return 'Buzz';
11 | return n.toString();
12 | }
13 |
14 | export function calculateAverage(numbers) {
15 | if (numbers.length === 0) return NaN;
16 |
17 | const sum = numbers.reduce((sum, current) => sum + current, 0);
18 | return sum / numbers.length;
19 | }
20 |
21 | export function factorial(n) {
22 | if (n < 0) return undefined;
23 | if (n === 0 || n === 1) return 1;
24 | return n * factorial(n - 1);
25 | }
26 |
--------------------------------------------------------------------------------
/src/libs/analytics.js:
--------------------------------------------------------------------------------
1 | import delay from 'delay';
2 |
3 | export async function trackPageView(pagePath) {
4 | console.log('Sending analytics...');
5 | console.log(`Path: ${pagePath}`);
6 | await delay(3000);
7 | }
8 |
--------------------------------------------------------------------------------
/src/libs/currency.js:
--------------------------------------------------------------------------------
1 | export const getExchangeRate = (from, to) => {
2 | console.log(`Getting the exchange rate ${from}-${to}...`);
3 | return Math.random();
4 | };
5 |
--------------------------------------------------------------------------------
/src/libs/email.js:
--------------------------------------------------------------------------------
1 | import delay from 'delay';
2 |
3 | export function isValidEmail(email) {
4 | const emailPattern = /^[A-Za-z0-9._%-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/;
5 |
6 | return emailPattern.test(email);
7 | }
8 |
9 | export async function sendEmail(to, message) {
10 | console.log(`Sending email to ${to}...`);
11 | console.log(`Message: ${message}`);
12 | await delay(3000);
13 | }
14 |
--------------------------------------------------------------------------------
/src/libs/payment.js:
--------------------------------------------------------------------------------
1 | import delay from 'delay';
2 |
3 | export async function charge(creditCardInfo, amount) {
4 | console.log(`Charging Credit Card: ${creditCardInfo.creditCardNumber}`);
5 | console.log(`Amount: ${amount}`);
6 | await delay(3000);
7 | return { status: 'success' };
8 | }
9 |
--------------------------------------------------------------------------------
/src/libs/security.js:
--------------------------------------------------------------------------------
1 | export function generateCode() {
2 | return Math.floor(Math.random() * (999999 - 100000 + 1)) + 100000;
3 | }
4 |
5 | export default {
6 | generateCode
7 | };
8 |
--------------------------------------------------------------------------------
/src/libs/shipping.js:
--------------------------------------------------------------------------------
1 | export function getShippingQuote(destination) {
2 | console.log(`Getting a shipping quote for ${destination}...`);
3 | return { cost: 10 * Math.random(), estimatedDays: 2 };
4 | }
5 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | export function calculateDiscount(price: number, discountCode: string) {
2 | if (price <= 0) {
3 | return 'Invalid price';
4 | }
5 |
6 | let discount = 0;
7 | if (discountCode === 'SAVE10') {
8 | discount = 0.1;
9 | } else if (discountCode === 'SAVE20') {
10 | discount = 0.2;
11 | }
12 |
13 | return price - price * discount;
14 | }
15 |
--------------------------------------------------------------------------------
/src/mocking.js:
--------------------------------------------------------------------------------
1 | import { trackPageView } from './libs/analytics';
2 | import { getExchangeRate } from './libs/currency';
3 | import { isValidEmail, sendEmail } from './libs/email';
4 | import { charge } from './libs/payment';
5 | import security from './libs/security';
6 | import { getShippingQuote } from './libs/shipping';
7 |
8 | // Lesson: Mocking modules
9 | export function getPriceInCurrency(price, currency) {
10 | const rate = getExchangeRate('USD', currency);
11 | return price * rate;
12 | }
13 |
14 | // Exercise
15 | export function getShippingInfo(destination) {
16 | const quote = getShippingQuote(destination);
17 | if (!quote) return 'Shipping Unavailable';
18 | return `Shipping Cost: $${quote.cost} (${quote.estimatedDays} Days)`;
19 | }
20 |
21 | // Lesson: Interaction testing
22 | export async function renderPage() {
23 | trackPageView('/home');
24 |
25 | return '
content
';
26 | }
27 |
28 | // Exercise
29 | export async function submitOrder(order, creditCard) {
30 | const paymentResult = await charge(creditCard, order.totalAmount);
31 |
32 | if (paymentResult.status === 'failed') { return { success: false, error: 'payment_error' }; }
33 |
34 | return { success: true };
35 | }
36 |
37 | // Lesson: Partial mocking
38 | export async function signUp(email) {
39 | if (!isValidEmail(email)) return false;
40 |
41 | await sendEmail(email, 'Welcome aboard!');
42 |
43 | return true;
44 | }
45 |
46 | // Lesson: Spying on functions
47 | export async function login(email) {
48 | const code = security.generateCode();
49 |
50 | await sendEmail(email, code.toString());
51 | }
52 |
53 | // Lesson: Mocking dates
54 | export function isOnline() {
55 | const availableHours = [8, 20];
56 | const [open, close] = availableHours;
57 | const currentHour = new Date().getHours();
58 |
59 | return currentHour >= open && currentHour < close;
60 | }
61 |
62 | // Exercise
63 | export function getDiscount() {
64 | const today = new Date();
65 | const isChristmasDay = today.getMonth() === 11 && today.getDate() === 25;
66 | return isChristmasDay ? 0.2 : 0;
67 | }
68 |
--------------------------------------------------------------------------------
/tests/core.test.js:
--------------------------------------------------------------------------------
1 | import { it, expect, describe, beforeEach } from 'vitest';
2 | import {
3 | Stack,
4 | calculateDiscount,
5 | canDrive,
6 | fetchData,
7 | getCoupons,
8 | isPriceInRange,
9 | isValidUsername,
10 | validateUserInput
11 | } from '../src/core';
12 |
13 | describe('getCoupons', () => {
14 | it('should return an array of coupons', () => {
15 | const coupons = getCoupons();
16 | expect(Array.isArray(coupons)).toBe(true);
17 | expect(coupons.length).toBeGreaterThan(0);
18 | });
19 |
20 | it('should return an array with valid coupon codes', () => {
21 | const coupons = getCoupons();
22 | coupons.forEach((coupon) => {
23 | expect(coupon).toHaveProperty('code');
24 | expect(typeof coupon.code).toBe('string');
25 | expect(coupon.code).toBeTruthy();
26 | });
27 | });
28 |
29 | it('should return an array with valid discounts', () => {
30 | const coupons = getCoupons();
31 | coupons.forEach((coupon) => {
32 | expect(coupon).toHaveProperty('discount');
33 | expect(typeof coupon.discount).toBe('number');
34 | expect(coupon.discount).toBeGreaterThan(0);
35 | expect(coupon.discount).toBeLessThan(1);
36 | });
37 | });
38 | });
39 |
40 | describe('calculateDiscount', () => {
41 | it('should return discounted price if given valid code', () => {
42 | expect(calculateDiscount(10, 'SAVE10')).toBe(9);
43 | expect(calculateDiscount(10, 'SAVE20')).toBe(8);
44 | });
45 |
46 | it('should handle non-numeric price', () => {
47 | expect(calculateDiscount('10', 'SAVE10')).toMatch(/invalid/i);
48 | });
49 |
50 | it('should handle negative price', () => {
51 | expect(calculateDiscount(-10, 'SAVE10')).toMatch(/invalid/i);
52 | });
53 |
54 | it('should handle non-string discount code', () => {
55 | expect(calculateDiscount(10, 10)).toMatch(/invalid/i);
56 | });
57 |
58 | it('should handle invalid discount code', () => {
59 | expect(calculateDiscount(10, 'INVALID')).toBe(10);
60 | });
61 | });
62 |
63 | describe('validateUserInput', () => {
64 | it('should return success if given valid input', () => {
65 | expect(validateUserInput('mosh', 42)).toMatch(/success/i);
66 | });
67 |
68 | it('should return an error if username is not a string', () => {
69 | expect(validateUserInput(1, 42)).toMatch(/invalid/i);
70 | });
71 |
72 | it('should return an error if username is less than 3 characters', () => {
73 | expect(validateUserInput('mo', 42)).toMatch(/invalid/i);
74 | });
75 |
76 | it('should return an error if username is longer than 255 characters', () => {
77 | expect(validateUserInput('A'.repeat(256), 42)).toMatch(/invalid/i);
78 | });
79 |
80 | it('should return an error if age is not a number', () => {
81 | expect(validateUserInput('mosh', '42')).toMatch(/invalid/i);
82 | });
83 |
84 | it('should return an error if age is less than 18', () => {
85 | expect(validateUserInput('mosh', 17)).toMatch(/invalid/i);
86 | });
87 |
88 | it('should return an error if age is greater than 100', () => {
89 | expect(validateUserInput('mosh', 101)).toMatch(/invalid/i);
90 | });
91 |
92 | it('should return an error if both username and age are invalid', () => {
93 | expect(validateUserInput('', 0)).toMatch(/invalid username/i);
94 | expect(validateUserInput('', 0)).toMatch(/invalid age/i);
95 | });
96 | });
97 |
98 | describe('isPriceInRange', () => {
99 | it.each([
100 | { scenario: 'price < min', price: -10, result: false },
101 | { scenario: 'price = min', price: 0, result: true },
102 | {
103 | scenario: 'price between min and max',
104 | price: 50,
105 | result: true
106 | },
107 | { scenario: 'price = max', price: 100, result: true },
108 | { scenario: 'price > max', price: 200, result: false }
109 | ])('should return $result when $scenario', ({ price, result }) => {
110 | expect(isPriceInRange(price, 0, 100)).toBe(result);
111 | });
112 | });
113 |
114 | describe('isValidUsername', () => {
115 | const minLength = 5;
116 | const maxLength = 15;
117 |
118 | it('should return false if username is too short', () => {
119 | expect(isValidUsername('a'.repeat(minLength - 1))).toBe(false);
120 | });
121 |
122 | it('should return false if username is too long', () => {
123 | expect(isValidUsername('a'.repeat(maxLength + 1))).toBe(false);
124 | });
125 |
126 | it('should return true if username is at the min or max length', () => {
127 | expect(isValidUsername('a'.repeat(minLength))).toBe(true);
128 | expect(isValidUsername('a'.repeat(maxLength))).toBe(true);
129 | });
130 |
131 | it('should return true if username is within the length constraint', () => {
132 | expect(isValidUsername('a'.repeat(minLength + 1))).toBe(true);
133 | expect(isValidUsername('a'.repeat(maxLength - 1))).toBe(true);
134 | });
135 |
136 | it('should return false for invalid input types', () => {
137 | expect(isValidUsername(null)).toBe(false);
138 | expect(isValidUsername(undefined)).toBe(false);
139 | expect(isValidUsername(1)).toBe(false);
140 | });
141 | });
142 |
143 | describe('canDrive', () => {
144 | it('should return error for invalid country code', () => {
145 | expect(canDrive(20, 'FR')).toMatch(/invalid/i);
146 | });
147 |
148 | it.each([
149 | { age: 15, country: 'US', result: false },
150 | { age: 16, country: 'US', result: true },
151 | { age: 17, country: 'US', result: true },
152 | { age: 16, country: 'UK', result: false },
153 | { age: 17, country: 'UK', result: true },
154 | { age: 18, country: 'UK', result: true }
155 | ])('should return $result for $age, $country', ({ age, country, result }) => {
156 | expect(canDrive(age, country)).toBe(result);
157 | });
158 | });
159 |
160 | describe('fetchData', () => {
161 | it('should return a promise that will resolve to an array of numbers', async () => {
162 | try {
163 | await fetchData();
164 | } catch (error) {
165 | expect(error).toHaveProperty('reason');
166 | expect(error.reason).toMatch(/fail/i);
167 | }
168 | });
169 | });
170 |
171 | describe('Stack', () => {
172 | let stack;
173 |
174 | beforeEach(() => {
175 | stack = new Stack();
176 | });
177 |
178 | it('push should add an item to the stack', () => {
179 | stack.push(1);
180 |
181 | expect(stack.size()).toBe(1);
182 | });
183 |
184 | it('pop should remove and return the top item from the stack', () => {
185 | stack.push(1);
186 | stack.push(2);
187 |
188 | const poppedItem = stack.pop();
189 |
190 | expect(poppedItem).toBe(2);
191 | expect(stack.size()).toBe(1);
192 | });
193 |
194 | it('pop should throw an error if stack is empty', () => {
195 | expect(() => stack.pop()).toThrow(/empty/i);
196 | });
197 |
198 | it('peek should return the top item from the stack without removing it', () => {
199 | stack.push(1);
200 | stack.push(2);
201 |
202 | const peekedItem = stack.peek();
203 |
204 | expect(peekedItem).toBe(2);
205 | expect(stack.size()).toBe(2);
206 | });
207 |
208 | it('peek should throw an error if stack is empty', () => {
209 | expect(() => stack.peek()).toThrow(/empty/i);
210 | });
211 |
212 | it('isEmpty should return true if stack is empty', () => {
213 | expect(stack.isEmpty()).toBe(true);
214 | });
215 |
216 | it('isEmpty should return false if stack is not empty', () => {
217 | stack.push(1);
218 |
219 | expect(stack.isEmpty()).toBe(false);
220 | });
221 |
222 | it('size should return the number of items in the stack', () => {
223 | stack.push(1);
224 | stack.push(2);
225 |
226 | expect(stack.size()).toBe(2);
227 | });
228 |
229 | it('clear should remove all items from the stack', () => {
230 | stack.push(1);
231 | stack.push(2);
232 |
233 | stack.clear();
234 |
235 | expect(stack.size()).toBe(0);
236 | });
237 | });
238 |
--------------------------------------------------------------------------------
/tests/intro.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import { calculateAverage, factorial, fizzBuzz, max } from '../src/intro';
3 |
4 | describe('max', () => {
5 | it('should return the first argument if it is greater', () => {
6 | expect(max(2, 1)).toBe(2);
7 | });
8 |
9 | it('should return the second argument if it is greater', () => {
10 | expect(max(1, 2)).toBe(2);
11 | });
12 |
13 | it('should return the first argument if arguments are equal', () => {
14 | expect(max(1, 1)).toBe(1);
15 | });
16 | });
17 |
18 | describe('fizzBuzz', () => {
19 | it('should return FizzBuzz if arg is divisible by 3 and 5', () => {
20 | expect(fizzBuzz(15)).toBe('FizzBuzz');
21 | });
22 |
23 | it('should return Fizz if arg is only divisible by 3', () => {
24 | expect(fizzBuzz(3)).toBe('Fizz');
25 | });
26 |
27 | it('should return Buzz if arg is only divisible by 5', () => {
28 | expect(fizzBuzz(5)).toBe('Buzz');
29 | });
30 |
31 | it('should return arg as a string if it is not divisible by 3 or 5', () => {
32 | expect(fizzBuzz(1)).toBe('1');
33 | });
34 | });
35 |
36 | describe('calculateAverage', () => {
37 | it('should return NaN if given an empty array', () => {
38 | expect(calculateAverage([])).toBe(NaN);
39 | });
40 |
41 | it('should calculate the average of an array with a single element', () => {
42 | expect(calculateAverage([1])).toBe(1);
43 | });
44 |
45 | it('should calculate the average of an array with two elements', () => {
46 | expect(calculateAverage([1, 2])).toBe(1.5);
47 | });
48 |
49 | it('should calculate the average of an array with three elements', () => {
50 | expect(calculateAverage([1, 2, 3])).toBe(2);
51 | });
52 | });
53 |
54 | describe('factorial', () => {
55 | it('should return 1 if given 0', () => {
56 | expect(factorial(0)).toBe(1);
57 | });
58 |
59 | it('should return 1 if given 1', () => {
60 | expect(factorial(1)).toBe(1);
61 | });
62 |
63 | it('should return 2 if given 2', () => {
64 | expect(factorial(2)).toBe(2);
65 | });
66 |
67 | it('should return 6 if given 3', () => {
68 | expect(factorial(3)).toBe(6);
69 | });
70 |
71 | it('should return 24 if given 4', () => {
72 | expect(factorial(4)).toBe(24);
73 | });
74 |
75 | it('should return undefined if given a negative number', () => {
76 | expect(factorial(-1)).toBeUndefined();
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/tests/main.test.ts:
--------------------------------------------------------------------------------
1 | import { it, expect, describe } from 'vitest';
2 | import { calculateDiscount } from '../src/main';
3 |
4 | describe('calculateDiscount', () => {
5 | it('should return discounted price if given valid code', () => {
6 | expect(calculateDiscount(10, 'SAVE10')).toBe(9);
7 | expect(calculateDiscount(10, 'SAVE20')).toBe(8);
8 | });
9 |
10 | it('should handle negative price', () => {
11 | expect(calculateDiscount(-10, 'SAVE10')).toMatch(/invalid/i);
12 | });
13 |
14 | it('should handle invalid discount code', () => {
15 | expect(calculateDiscount(10, 'INVALID')).toBe(10);
16 | });
17 | });
--------------------------------------------------------------------------------
/tests/mocking.test.js:
--------------------------------------------------------------------------------
1 | import { vi, it, expect, describe } from 'vitest';
2 | import {
3 | getDiscount,
4 | getPriceInCurrency,
5 | getShippingInfo,
6 | isOnline,
7 | login,
8 | renderPage,
9 | signUp,
10 | submitOrder
11 | } from '../src/mocking';
12 | import { getExchangeRate } from '../src/libs/currency';
13 | import { getShippingQuote } from '../src/libs/shipping';
14 | import { trackPageView } from '../src/libs/analytics';
15 | import { charge } from '../src/libs/payment';
16 | import { sendEmail } from '../src/libs/email';
17 | import security from '../src/libs/security';
18 |
19 | vi.mock('../src/libs/currency');
20 | vi.mock('../src/libs/shipping');
21 | vi.mock('../src/libs/analytics');
22 | vi.mock('../src/libs/payment');
23 | vi.mock('../src/libs/email', async (importOriginal) => {
24 | const originalModule = await importOriginal();
25 | return {
26 | ...originalModule,
27 | sendEmail: vi.fn()
28 | };
29 | });
30 |
31 | describe('test suite', () => {
32 | it('test case', () => {
33 | // Create a mock for the following function
34 | const sendText = vi.fn();
35 | sendText.mockReturnValue('ok');
36 |
37 | // Call the mock function
38 | const result = sendText('message');
39 |
40 | // Assert that the mock function is called
41 | expect(sendText).toHaveBeenCalledWith('message');
42 | // Assert that the result is 'ok'
43 | expect(result).toBe('ok');
44 | });
45 | });
46 |
47 | describe('getPriceInCurrency', () => {
48 | it('should return price in target currency', () => {
49 | vi.mocked(getExchangeRate).mockReturnValue(1.5);
50 |
51 | const price = getPriceInCurrency(10, 'AUD');
52 |
53 | expect(price).toBe(15);
54 | });
55 | });
56 |
57 | describe('getShippingInfo', () => {
58 | it('should return shipping unavailable if quote cannot be fetched', () => {
59 | vi.mocked(getShippingQuote).mockReturnValue(null);
60 |
61 | const result = getShippingInfo('London');
62 |
63 | expect(result).toMatch(/unavailable/i);
64 | });
65 |
66 | it('should return shipping info if quote can be fetched', () => {
67 | vi.mocked(getShippingQuote).mockReturnValue({ cost: 10, estimatedDays: 2 });
68 |
69 | const result = getShippingInfo('London');
70 |
71 | expect(result).toMatch('$10');
72 | expect(result).toMatch(/2 days/i);
73 | expect(result).toMatch(/shipping cost: \$10 \(2 days\)/i);
74 | });
75 | });
76 |
77 | describe('renderPage', () => {
78 | it('should return correct content', async () => {
79 | const result = await renderPage();
80 |
81 | expect(result).toMatch(/content/i);
82 | });
83 |
84 | it('should call analytics', async () => {
85 | await renderPage();
86 |
87 | expect(trackPageView).toHaveBeenCalledWith('/home');
88 | });
89 | });
90 |
91 | describe('submitOrder', () => {
92 | const order = { totalAmount: 10 };
93 | const creditCard = { creditCardNumber: '1234' };
94 |
95 | it('should charge the customer', async () => {
96 | vi.mocked(charge).mockResolvedValue({ status: 'success' });
97 |
98 | await submitOrder(order, creditCard);
99 |
100 | expect(charge).toHaveBeenCalledWith(creditCard, order.totalAmount);
101 | });
102 |
103 | it('should return success when payment is successful', async () => {
104 | vi.mocked(charge).mockResolvedValue({ status: 'success' });
105 |
106 | const result = await submitOrder(order, creditCard);
107 |
108 | expect(result).toEqual({ success: true });
109 | });
110 |
111 | it('should return success when payment is successful', async () => {
112 | vi.mocked(charge).mockResolvedValue({ status: 'failed' });
113 |
114 | const result = await submitOrder(order, creditCard);
115 |
116 | expect(result).toEqual({ success: false, error: 'payment_error' });
117 | });
118 | });
119 |
120 | describe('signUp', () => {
121 | const email = 'name@domain.com';
122 |
123 | it('should return false if email is not valid', async () => {
124 | const result = await signUp('a');
125 |
126 | expect(result).toBe(false);
127 | });
128 |
129 | it('should return true if email is valid', async () => {
130 | const result = await signUp(email);
131 |
132 | expect(result).toBe(true);
133 | });
134 |
135 | it('should send the welcome email if email is valid', async () => {
136 | await signUp(email);
137 |
138 | expect(sendEmail).toHaveBeenCalledOnce();
139 | const args = vi.mocked(sendEmail).mock.calls[0];
140 | expect(args[0]).toBe(email);
141 | expect(args[1]).toMatch(/welcome/i);
142 | });
143 | });
144 |
145 | describe('login', () => {
146 | it('should email the one-time login code', async () => {
147 | const email = 'name@domain.com';
148 | const spy = vi.spyOn(security, 'generateCode');
149 |
150 | await login(email);
151 |
152 | const securityCode = spy.mock.results[0].value.toString();
153 | expect(sendEmail).toHaveBeenCalledWith(email, securityCode);
154 | });
155 | });
156 |
157 | describe('isOnline', () => {
158 | it('should return false if current hour is outside opening hours', () => {
159 | vi.setSystemTime('2024-01-01 07:59');
160 | expect(isOnline()).toBe(false);
161 |
162 | vi.setSystemTime('2024-01-01 20:01');
163 | expect(isOnline()).toBe(false);
164 | });
165 |
166 | it('should return true if current hour is within opening hours', () => {
167 | vi.setSystemTime('2024-01-01 08:00');
168 | expect(isOnline()).toBe(true);
169 |
170 | vi.setSystemTime('2024-01-01 19:59');
171 | expect(isOnline()).toBe(true);
172 | });
173 | });
174 |
175 | describe('getDiscount', () => {
176 | it('should return .2 on Christmas day', () => {
177 | vi.setSystemTime('2024-12-25 00:01');
178 | expect(getDiscount()).toBe(0.2);
179 |
180 | vi.setSystemTime('2024-12-25 23:59');
181 | expect(getDiscount()).toBe(0.2);
182 | });
183 |
184 | it('should return 0 on any other day', () => {
185 | vi.setSystemTime('2024-12-24 00:01');
186 | expect(getDiscount()).toBe(0);
187 |
188 | vi.setSystemTime('2024-12-26 00:01');
189 | expect(getDiscount()).toBe(0);
190 | });
191 | });
192 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
26 |
27 | /* Modules */
28 | "module": "commonjs", /* Specify what module code is generated. */
29 | // "rootDir": "./", /* Specify the root folder within your source files. */
30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
42 | // "resolveJsonModule": true, /* Enable importing .json files. */
43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
45 |
46 | /* JavaScript Support */
47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
50 |
51 | /* Emit */
52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
58 | "outDir": "./dist", /* Specify an output folder for all emitted files. */
59 | // "removeComments": true, /* Disable emitting comments. */
60 | // "noEmit": true, /* Disable emitting files from a compilation. */
61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
68 | // "newLine": "crlf", /* Set the newline character for emitting files. */
69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
75 |
76 | /* Interop Constraints */
77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
83 |
84 | /* Type Checking */
85 | "strict": true, /* Enable all strict type-checking options. */
86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
104 |
105 | /* Completeness */
106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/vitest.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | clearMocks: true
6 | }
7 | });
8 |
--------------------------------------------------------------------------------