├── .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 | --------------------------------------------------------------------------------