๐ ๏ธ Weekend Project: Build a Modern JavaScript App with Webpack, Babel & Tests
This is the project where you stop writing loose scripts and start building a real application the way a professional team does. You'll scaffold a Task Manager with npm, split it into ES6 modules, bundle it with Webpack 5 (Vite offered as an alternative), transpile it with Babel 7, and lock its behaviour down with Jest unit tests. By Sunday you'll have a tested app and a production build you could deploy.
Week 3 · Weekend Project · Tooling Capstone
๐ฏ Learning Objectives
By completing this project, you will be able to:
- Scaffold a JavaScript project with
npm initand install dev dependencies you actually understand - Split an app into ES6 modules using
export/import, backed by ES6 classes - Configure Webpack 5 and Babel 7 to bundle and transpile source into a browser-ready build
- Write and run Jest unit tests, and read the pass/fail output to drive your code
- Wire a small UI to a service layer so business logic stays out of the DOM code
- Produce an optimized production build with hashed filenames and source maps
Estimated Time: 5โ7 hours across the weekend
Project: A tested, bundled Task Manager app with add/complete/filter/persist features.
In This Project
The Goal
Build a Task Manager โ add tasks, mark them complete, filter by status, and persist them across reloads โ but the app itself is only half the point. The real deliverable is the workflow: a project you scaffolded, modularized, tested, and bundled. This is the shape of every professional JavaScript codebase, from a startup side-project to a framework app.
Everything you write lives as clean source in src/. A build step transforms it into a bundle the browser loads. Keep this pipeline in mind โ each stage below is one box in it:
import / export"] --> B[Babel 7
transpiles modern JS] B --> C[Webpack 5
bundles & resolves] C --> D["dist/ bundle.js
+ index.html"] D --> E[Browser runs the app] A --> F[Jest
runs unit tests] F -->|green| C F -->|red| A
Notice the loop at the bottom: Jest reads the same source your bundle does. Green tests are your permission to build; a red test sends you back to the module โ not to the browser to click around and guess. That feedback loop is what "modern tooling" really buys you.
๐ Bundler vs transpiler
A transpiler (Babel) rewrites modern JavaScript down to syntax older browsers understand. A bundler (Webpack/Vite) follows your import statements and stitches every module into as few files as possible, handing each to Babel on the way through. Babel makes the code compatible; Webpack makes it deliverable.
Prerequisites
This is the Week 3 capstone, so it assumes the "Modern JavaScript & Tooling" week is fresh. Before you start, make sure you're comfortable with:
- ES6 modules โ
export default, namedexport, and matchingimport - Classes โ
constructor, methods, andstaticmethods - Array methods โ
map,filter,findfrom Week 2 - npm basics โ what
package.json,node_modules, and a dev dependency are - The TDD mindset โ write a failing test, make it pass, refactor
You'll need Node.js 18+ installed (it brings npm with it), a code editor, and a terminal. Check with node --version and npm --version first.
Required Features Checklist
These are the non-negotiables โ all achievable with this week's tooling, no framework required. Tick each off as you go.
โ Must-have features
- โ A project scaffolded with
npm initandnpm runscripts forstart,build,test - โ Source split into ES6 modules โ a
Taskmodel and aTaskManagerservice as ES6 classes, plus UI components - โ Add, complete/uncomplete, delete, and filter tasks (all / active / completed)
- โ Tasks persist across reloads via
localStorage - โ A Webpack 5 (or Vite) config with a dev server and a production build
- โ Babel 7 transpilation wired into the build
- โ A Jest suite that passes for the model and the service
Project Structure
Here's the layout you're building toward. The golden rule: everything you author lives in src/; dist/ is generated by the build and never edited by hand (git-ignore it). Config files sit at the root where the tools look for them.
task-manager/
โโโ package.json <-- scripts + dependencies
โโโ webpack.config.js <-- how to bundle
โโโ babel.config.js <-- how to transpile
โโโ jest.config.js <-- how to test
โโโ .gitignore <-- ignores node_modules/ and dist/
โโโ src/
โ โโโ index.html <-- template Webpack injects the bundle into
โ โโโ js/
โ โ โโโ models/
โ โ โ โโโ Task.js <-- the data (ES6 class)
โ โ โโโ services/
โ โ โ โโโ TaskManager.js <-- the logic + persistence
โ โ โโโ components/
โ โ โ โโโ TaskForm.js <-- UI: add a task
โ โ โ โโโ TaskList.js <-- UI: render tasks
โ โ โ โโโ TaskFilter.js <-- UI: filter buttons
โ โ โโโ app.js <-- entry point that wires it together
โ โโโ css/
โ โ โโโ styles.css
โ โโโ __tests__/
โ โโโ Task.test.js
โ โโโ TaskManager.test.js
โโโ dist/ <-- GENERATED by `npm run build` (git-ignored)
Each module has one job: Task knows what a task is, TaskManager knows how to store and query tasks, and the components know how to draw them. That separation is exactly what makes the app testable โ you can unit-test TaskManager with no browser in sight, because it never touches the DOM.
Stage 1 โ Scaffold with npm
Every modern JS project starts the same way: create a folder, initialize npm, and install your tools as dev dependencies (needed to build the app, not to run the shipped bundle). Run these in your terminal:
# Create the project and enter it
mkdir task-manager && cd task-manager
# Create package.json with sensible defaults (the -y skips the prompts)
npm init -y
# Webpack 5 + its CLI and dev server
npm install --save-dev webpack webpack-cli webpack-dev-server
# Babel 7: core, the modern-JS preset, and the Webpack loader
npm install --save-dev @babel/core @babel/preset-env babel-loader
# Jest, plus babel-jest so tests understand ES modules
npm install --save-dev jest babel-jest jest-environment-jsdom
# Webpack plugins/loaders: HTML injection + CSS handling
npm install --save-dev html-webpack-plugin css-loader style-loader mini-css-extract-plugin
Then create the source folders in one go, and add the scripts you'll run every day to package.json:
mkdir -p src/js/models src/js/services src/js/components src/css src/__tests__
{
"name": "task-manager",
"version": "1.0.0",
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --mode production",
"test": "jest",
"test:watch": "jest --watch"
}
}
๐ --save-dev vs --save
Tools that only run during development โ bundlers, transpilers, test runners โ go in devDependencies with --save-dev. Libraries whose code actually ships inside your bundle go in dependencies (plain --save). Getting this split right keeps your production install lean.
Stage 2 โ Configure the Toolchain
Three small config files teach your tools how to behave. Create each at the project root.
Webpack โ webpack.config.js
Webpack needs an entry (where to start following imports), an output (where to write the bundle), and rules (how to handle each file type). Exporting a function that receives argv.mode lets one config serve dev and production both.
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
return {
entry: './src/js/app.js', // start here and follow every import
output: {
path: path.resolve(__dirname, 'dist'),
// Content-hash the filename in prod so browsers cache-bust correctly
filename: isProduction ? '[name].[contenthash].js' : '[name].js',
clean: true // wipe dist/ before each build (Webpack 5 built-in)
},
module: {
rules: [
// Every .js file except third-party code gets handed to Babel:
{ test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' },
// Inject <style> tags in dev; extract to a .css file in prod:
{ test: /\.css$/, use: [
isProduction ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader'
] }
]
},
plugins: [
// Webpack injects the hashed bundle <script> into this template for you:
new HtmlWebpackPlugin({ template: './src/index.html' }),
isProduction && new MiniCssExtractPlugin({ filename: '[name].[contenthash].css' })
].filter(Boolean), // drop the `false` when not producing
devServer: { static: './dist', hot: true, open: true }, // hot reload + auto-open
devtool: isProduction ? 'source-map' : 'eval-source-map' // debuggable stack traces
};
};
โ ๏ธ A Webpack 5 modernization
Older tutorials install clean-webpack-plugin separately โ Webpack 5 replaces it with the built-in output.clean: true shown above, so skip the extra package. (Webpack 5 also no longer auto-polyfills Node core modules, the classic "worked in Webpack 4" gotcha, though this browser-only project won't hit it.)
Babel โ babel.config.js
One preset does almost everything: @babel/preset-env reads your target browsers and transpiles exactly as much as it must. The same config is picked up by both babel-loader (build) and babel-jest (tests), so your tests run the same transpiled code your app does.
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {
// "Support browsers with >0.25% share that aren't dead" โ a sane default.
targets: '> 0.25%, not dead'
}]
]
};
Jest โ jest.config.js
Jest runs in Node, which has no document or localStorage. The jsdom environment fakes a browser so your DOM code and localStorage persistence can be tested without a real page. The moduleNameMapper line stops a CSS import inside a tested module from crashing by mapping it to an empty stub.
// jest.config.js
module.exports = {
testEnvironment: 'jsdom', // simulate window, document, localStorage
moduleNameMapper: {
'\\.(css|scss)$': '<rootDir>/src/__mocks__/styleMock.js' // stub file: module.exports = {}
}
};
Stage 3 โ Build Feature Modules
Now the application logic โ and the best part is that none of it touches the DOM. That's deliberate: pure logic is easy to reason about and trivial to test. Two modules carry the app.
The Task model
A Task is a small ES6 class: it holds the data and knows a few things about itself (whether it's overdue, how to serialize). The toJSON/fromJSON pair is what lets us store tasks as text and rebuild them later.
// src/js/models/Task.js
export default class Task {
constructor(title, description = '', options = {}) {
// A collision-resistant id (modern engines also offer crypto.randomUUID()):
this.id = Date.now().toString(36) + Math.random().toString(36).slice(2);
this.title = title;
this.description = description;
this.completed = false;
this.createdAt = new Date();
this.completedAt = null;
this.dueDate = options.dueDate || null;
this.priority = options.priority || 'normal';
}
complete() { this.completed = true; this.completedAt = new Date(); }
uncomplete() { this.completed = false; this.completedAt = null; }
isOverdue() {
if (!this.dueDate || this.completed) return false;
return new Date() > this.dueDate;
}
// Turn the task into a plain, storable object (Dates โ ISO strings).
toJSON() {
return { ...this,
createdAt: this.createdAt.toISOString(),
completedAt: this.completedAt ? this.completedAt.toISOString() : null,
dueDate: this.dueDate ? this.dueDate.toISOString() : null
};
}
// Rebuild a real Task (with methods + Date objects) from stored JSON.
static fromJSON(json) {
const task = new Task(json.title, json.description, {
dueDate: json.dueDate ? new Date(json.dueDate) : null,
priority: json.priority
});
Object.assign(task, {
id: json.id,
completed: json.completed,
createdAt: new Date(json.createdAt),
completedAt: json.completedAt ? new Date(json.completedAt) : null
});
return task;
}
}
๐ก slice(2), not the old substr(2)
Older code writes Math.random().toString(36).substr(2), but String.prototype.substr() is a legacy, deprecated method. Reach for slice(2) instead โ it drops the leading "0." and gives you the random tail with one fewer deprecation flag.
The TaskManager service
The service owns the array of tasks, every operation on it, and persistence. Because it's a plain class with no UI, it's the easiest thing in the app to test โ which is exactly what Stage 5 does.
// src/js/services/TaskManager.js
import Task from '../models/Task';
export default class TaskManager {
constructor() {
this.tasks = [];
this.loadTasks(); // rehydrate from localStorage on startup
}
loadTasks() {
const saved = localStorage.getItem('tasks');
// Map each stored plain object back into a real Task instance.
if (saved) this.tasks = JSON.parse(saved).map(Task.fromJSON);
}
saveTasks() {
localStorage.setItem('tasks', JSON.stringify(this.tasks));
}
addTask(title, description = '', options = {}) {
const task = new Task(title, description, options);
this.tasks.push(task);
this.saveTasks();
return task; // return it so the caller can chain, e.g. .complete()
}
removeTask(id) {
this.tasks = this.tasks.filter(task => task.id !== id);
this.saveTasks();
}
getTask(id) { return this.tasks.find(task => task.id === id); }
getAllTasks() { return [...this.tasks]; } // a copy, so callers can't mutate our array
getFilteredTasks(filter = 'all') {
switch (filter) {
case 'active': return this.tasks.filter(t => !t.completed);
case 'completed': return this.tasks.filter(t => t.completed);
default: return this.getAllTasks();
}
}
}
Stage 4 โ Wire the UI
With the logic done, the UI is thin. Each component is a class that owns a DOM element and calls back to the app when the user acts โ it never touches TaskManager directly. The entry point (app.js) is the wiring between them.
The three components
Each component owns a DOM element and a callback, following one shape โ build the element, attach a listener, call back to the app. TaskForm is the template for all three:
// src/js/components/TaskForm.js
export default class TaskForm {
constructor(onSubmit) {
this.onSubmit = onSubmit; // callback into the app
this.element = document.createElement('form');
this.element.className = 'task-form';
this.element.innerHTML = `
<input type="text" name="title" placeholder="Task title" required>
<select name="priority" aria-label="Priority">
<option value="low">Low</option>
<option value="normal" selected>Normal</option>
<option value="high">High</option>
</select>
<button type="submit">Add Task</button>`;
this.element.addEventListener('submit', (e) => {
e.preventDefault();
const data = new FormData(e.target);
this.onSubmit({ title: data.get('title'), priority: data.get('priority') });
e.target.reset();
});
}
}
The other two follow suit. TaskList.render(tasks) builds an <li> per task with a checkbox and a delete button, wiring each to onToggle(task.id) / onDelete(task.id). TaskFilter is three buttons that call onFilterChange('all' | 'active' | 'completed') and highlight the active one.
The entry point โ app.js
This is the file Webpack starts from. It imports every module (including the CSS โ Webpack turns that import into a real stylesheet), constructs the service and components, and defines the handlers that glue them.
// src/js/app.js
import TaskManager from './services/TaskManager';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import TaskFilter from './components/TaskFilter';
import '../css/styles.css'; // Webpack handles this import, not the browser
class TaskApp {
constructor(root) {
this.taskManager = new TaskManager();
this.currentFilter = 'all';
this.form = new TaskForm(this.handleAdd.bind(this));
this.list = new TaskList(this.handleToggle.bind(this), this.handleDelete.bind(this));
this.filter = new TaskFilter(this.handleFilter.bind(this));
root.append(this.form.element, this.filter.element, this.list.element);
this.render();
}
handleAdd({ title, priority }) {
this.taskManager.addTask(title, '', { priority });
this.render();
}
handleToggle(id) {
const task = this.taskManager.getTask(id);
task.completed ? task.uncomplete() : task.complete();
this.taskManager.saveTasks();
this.render();
}
handleDelete(id) { this.taskManager.removeTask(id); this.render(); }
handleFilter(filter) { this.currentFilter = filter; this.render(); }
render() {
this.list.render(this.taskManager.getFilteredTasks(this.currentFilter));
}
}
// Boot once the DOM exists.
document.addEventListener('DOMContentLoaded', () => {
new TaskApp(document.getElementById('app'));
});
The src/index.html template is deliberately bare โ a standard <head> plus a body containing just <main id="app"></main> where the components mount. Crucially it has no <script> tag: HtmlWebpackPlugin injects the bundle with its correct hashed filename for you at build time.
โ ๏ธ innerHTML and user input
Building list items with template strings like ${task.title} is concise, but injecting unescaped user input into innerHTML is an XSS risk โ a task titled <img onerror=โฆ> could run code. For production, build nodes with document.createElement and set textContent, or escape the values. Worth knowing now, even if you keep the simple version this weekend.
Stage 5 โ Add Jest Tests
Here's the payoff for keeping logic out of the DOM: you can test the whole "brain" of the app with no browser. A Jest test imports a module, calls it, and asserts with expect(...). Run npm test and Jest finds every *.test.js file automatically.
Testing the Task model
// src/__tests__/Task.test.js
import Task from '../js/models/Task';
describe('Task', () => {
test('creates a task with sensible defaults', () => {
const task = new Task('Buy groceries', 'Milk and eggs');
expect(task.id).toBeDefined();
expect(task.title).toBe('Buy groceries');
expect(task.completed).toBe(false);
expect(task.createdAt).toBeInstanceOf(Date);
});
test('complete() flips the flag and stamps a time', () => {
const task = new Task('Test');
task.complete();
expect(task.completed).toBe(true);
expect(task.completedAt).toBeInstanceOf(Date);
});
test('toJSON/fromJSON round-trips a task', () => {
const restored = Task.fromJSON(new Task('Round trip', '', { priority: 'high' }).toJSON());
expect(restored.priority).toBe('high');
expect(restored.createdAt).toBeInstanceOf(Date); // rebuilt as a real Date, not a string
});
});
Testing the TaskManager service
beforeEach gives every test a clean slate โ a fresh manager and an empty localStorage โ so tests never leak into each other.
// src/__tests__/TaskManager.test.js
import TaskManager from '../js/services/TaskManager';
describe('TaskManager', () => {
let manager;
beforeEach(() => {
localStorage.clear(); // jsdom gives us a working localStorage
manager = new TaskManager();
});
test('addTask stores a task; removeTask deletes it by id', () => {
const task = manager.addTask('Write tests');
expect(manager.getAllTasks()).toHaveLength(1);
manager.removeTask(task.id);
expect(manager.getAllTasks()).toHaveLength(0);
});
test('getFilteredTasks separates active from completed', () => {
manager.addTask('Done').complete();
manager.addTask('Todo');
expect(manager.getFilteredTasks('active')).toHaveLength(1);
expect(manager.getFilteredTasks('completed')).toHaveLength(1);
});
test('tasks persist to localStorage across instances', () => {
manager.addTask('Persisted');
const reloaded = new TaskManager(); // simulates a page refresh
expect(reloaded.getAllTasks()).toHaveLength(1);
});
});
Expected output of npm test
PASS src/__tests__/Task.test.js
PASS src/__tests__/TaskManager.test.js
Test Suites: 2 passed, 2 total
Tests: 7 passed, 7 total
๐๏ธ Exercise: Test-drive a new method
Goal: Add a clearCompleted() method to TaskManager that deletes every completed task โ but write the test first, watch it fail, then make it pass.
๐ก Hint
Write a test that adds two tasks, completes one, calls manager.clearCompleted(), and expects getAllTasks() to have length 1. Then implement the method with a filter and a saveTasks().
โ Solution
// test โ write this FIRST; it should fail before the method exists
test('clearCompleted removes only completed tasks', () => {
manager.addTask('Keep');
manager.addTask('Remove').complete();
manager.clearCompleted();
expect(manager.getAllTasks().map(t => t.title)).toEqual(['Keep']);
});
// implementation (add to TaskManager)
clearCompleted() {
this.tasks = this.tasks.filter(task => !task.completed);
this.saveTasks();
}
Stage 6 โ Production Build
During development, npm start launches webpack-dev-server with hot reload at http://localhost:8080. When you're ready to ship, npm run build produces an optimized bundle in dist/.
npm start # dev: hot-reloading server, unminified, opens the browser
npm test # run the suite (do this before every build)
npm run build # production: minified, hashed filenames, CSS extracted, source maps
The two modes differ on purpose. Development favours speed and debuggability โ unminified output, plain main.js, CSS via <style> tags, fast eval-source-map rebuilds. Production favours a small, cacheable payload โ minified code, content-hashed filenames, CSS extracted to its own file, and accurate source-maps.
After a successful build, dist/ holds a self-contained site โ an index.html, a hashed JS bundle, and a hashed CSS file. Drag that folder onto Netlify, GitHub Pages, or Vercel and your app is live.
๐ Prefer Vite? Same project, less config
Webpack teaches you what a bundler does. But many new projects reach for Vite instead โ near-zero config and a near-instant dev server via esbuild and native ES modules (Babel isn't even needed for modern targets). The exact same src/ modules and Jest tests work; you just swap the tooling:
npm create vite@latest task-manager -- --template vanilla
cd task-manager && npm install
npm run dev # instant dev server (Vite's `npm start`)
npm run build # production bundle in dist/ (then `npm run preview` to check it)
Stretch Goals
Finished the required build with time to spare? Level it up โ pick whichever excites you; none are needed to pass the rubric.
- ๐ Search โ add
searchTasks(query)and a search input that re-renders as you type - โ๏ธ Edit in place โ click a task title to edit it (a good excuse to add an
updateTask(id, changes)method) - ๐ท๏ธ Categories/tags โ a new field on
Taskplus a filter dimension - ๐ Coverage report โ run
npm test -- --coverageand push every module above 80% - ๐ Deploy it โ ship
dist/to Netlify or Vercel and share the URL - โก Migrate to Vite โ port to the Vite setup above and compare dev-server speed
Start with search โ the pattern mirrors getFilteredTasks. Write the failing test first, then add a searchTasks(query) method that lower-cases the query and returns tasks whose title or description .includes() it, and wire an input event on a search box to re-render with the results.
Self-Check Rubric
Before you call this done, grade yourself against the rubric. Aim to answer "yes" to everything in the first two columns โ the stretch column is bonus.
| Area | Meets expectations (required) | Exceeds (stretch) |
|---|---|---|
| Scaffolding | package.json with start/build/test scripts; deps as devDependencies |
.gitignore excludes node_modules/ and dist/; a README documents setup |
| Modules | Code split into model, service, and components with import/export |
Clear single-responsibility per file; no logic leaking into the UI layer |
| Bundler & Babel | npm start serves the app; npm run build emits a working dist/ |
Hashed filenames + extracted CSS in prod; or a working Vite port |
| Features | Add, complete/uncomplete, delete, filter, and persist all work | Search, edit-in-place, or categories added |
| Tests | npm test is all green for the model and the service |
A new method was test-driven; coverage report above 80% |
| Quality | No console errors; production build loads and runs | Deployed live with a shareable URL; XSS-safe rendering |
๐งช Final testing checklist
- โ
npm testpasses with zero failing tests - โ
npm startopens the app; adding a task renders it immediately - โ Completing a task strikes it through; the filter buttons show the right subset
- โ Refreshing the page keeps your tasks (localStorage persistence works)
- โ
npm run buildfinishes with no errors and createsdist/ - โ No red errors in the console;
dist/andnode_modules/are git-ignored
Summary
๐ What You Built
- A project scaffolded with npm and driven by
npm runscripts - An app split into ES6 modules โ a
Taskmodel, aTaskManagerservice, and thin UI components - A Webpack 5 + Babel 7 toolchain that bundles and transpiles source into a browser-ready build (with a Vite alternative in your back pocket)
- A green Jest suite that tests the app's logic with no browser required
- An optimized production build in
dist/, ready to deploy
This project is proof that Week 3 stuck. You went from writing scripts to engineering an application โ modularized, tested, bundled, shippable. That scaffold-modularize-test-build loop is the backbone of every professional JavaScript codebase, and exactly the ground a framework builds on.
๐ Additional Resources
- Webpack โ Core Concepts
- Babel โ
@babel/preset-env - Jest โ Getting Started
- Vite โ Getting Started
- MDN โ JavaScript modules
๐ What's Next?
Week 3 is complete โ you can now build, test, and ship a modern JavaScript app from scratch. Next week we put this tooling to work behind a framework. First up: What is React and why use it? โ where the component thinking you just practised (a TaskForm, a TaskList) is exactly what React formalizes, on a build setup much like the one you wired by hand.
๐ You finished Week 3!
You've got a tested, bundled app and a professional workflow to match. That's three real projects in your portfolio now โ and you're ready for React.