Modules, Tooling, and First Tests
Splitting code across files with export/import, what package.json and a lockfile actually do, and writing a first unit test with Arrange/Act/Assert.
By the end of this lesson, you can
- Split code across files using named and default exports, and explain what module scope means
- Read a package.json well enough to identify its dependencies, scripts, and the difference between dependencies and devDependencies
- Explain why a lockfile is committed to version control alongside package.json
- Write a unit test for a pure function using the Arrange/Act/Assert structure, including a boundary case
Why it matters
Every real program eventually outgrows a single file. Modules are how JavaScript splits code into
manageable, independently reasoned-about pieces; package.json and a lockfile are how a project
records what it depends on so it builds the same way for every contributor; and a first test is how
you check a function keeps working as the rest of the program changes around it.
Modules: export and import
A module is a single file with its own scope — nothing inside it is visible elsewhere unless explicitly exported, and nothing from another module is visible unless explicitly imported:
export function add(a, b) {
return a + b;
}
export const PI_ROUNDED = 3.14;
export default function multiply(a, b) {
return a * b;
}// app.js
import multiply, { add, PI_ROUNDED } from './math.js';
console.log(add(2, 3)); // 5
console.log(multiply(2, 3)); // 6
console.log(PI_ROUNDED); // 3.14
A module can have any number of named exports (export function, export const, imported with
{ }), but at most one default export (imported without { }, and can be given any local
name).
package.json and dependencies
package.json describes a Node project: its name, its scripts, and what it depends on.
{
"name": "my-app",
"type": "module",
"scripts": {
"start": "node index.js",
"test": "vitest run"
},
"dependencies": {
"some-library": "^2.1.0"
},
"devDependencies": {
"vitest": "^4.0.0"
}
}
| Field | Meaning | Example |
|---|---|---|
| dependencies | Needed to run the finished program | A date-formatting library the app actually calls |
| devDependencies | Needed only during development | A test runner, a linter, a bundler |
Running npm install some-library adds it to dependencies; npm install --save-dev vitest adds
it to devDependencies. The scripts object defines named commands runnable with npm run scriptName (a few conventional names, like start and test, can be run without run).
Why the lockfile matters
npm install also writes (or updates) package-lock.json — recording the exact version of
every dependency, and every dependency of those dependencies, that was actually installed.
package.json’s version ranges (^2.1.0) allow some flexibility; the lockfile pins an exact,
reproducible set.
A first unit test
A unit test checks one small piece of behavior — often a single pure function — in isolation. The Arrange/Act/Assert structure keeps a test’s intent clear: set up the input, run the code under test, check the result.
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './pricing.js';
describe('calculateDiscount', () => {
it('applies a percentage discount to a price', () => {
// Arrange
const price = 100;
const percentOff = 20;
// Act
const result = calculateDiscount(price, percentOff);
// Assert
expect(result).toBe(80);
});
it('returns the original price when percentOff is 0', () => {
// Arrange / Act
const result = calculateDiscount(50, 0);
// Assert — a boundary case, not just the typical one
expect(result).toBe(50);
});
});Testing 0 alongside a typical value above is deliberate — a boundary case (an edge of the
input range: zero, empty, the maximum, the minimum) is exactly where off-by-one and edge-case bugs
tend to hide, and where “looks right for the obvious case” tests provide the least protection.
Check your understanding
Or reveal the answer without checking
Answer:Both B and C are valid
A default export is imported without braces, given any local name (import multiply, { add } from ...), or explicitly as `default as multiply` inside braces — both B and C are equivalent, valid ways to import the same two exports.
Or reveal the answer without checking
Answer:They could end up with different versions of a dependency (or its own dependencies) if newer compatible releases came out in between
package.json's ^-prefixed version ranges allow any compatible newer release. Without a lockfile pinning exact versions, two installs at different times can resolve to different actual versions, undermining reproducibility.
Practice: warm-up
Given a file strings.js with export function shout(text) { return text.toUpperCase() + '!'; }
and no default export, write the correct import statement and a call that logs "HELLO!".
Stuck? Reveal one hint at a time.
Hint 1
A named export (no default keyword) is always imported inside curly braces, using its exact exported name.
Hint 2
There's no default export here, so importing without braces (import shout from ...) would not work.
Reveal the answer
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
import { shout } from './strings.js';
console.log(shout('hello'));Practice: apply it
Write a pure function clampScore(score, min, max) that returns score, clamped to the inclusive
range [min, max]. Then write three Arrange/Act/Assert tests: a value inside the range, a value
above max, and a value exactly equal to min (a boundary case).
Stuck? Reveal one hint at a time.
Hint 1
Math.max and Math.min compose to clamp a value: Math.min(Math.max(score, min), max).
Hint 2
A boundary test at exactly min or max checks the "inclusive" part of the range specifically — an off-by-one bug would most likely show up right there.
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
export function clampScore(score, min, max) {
return Math.min(Math.max(score, min), max);
}import { describe, it, expect } from 'vitest';
import { clampScore } from './scoring.js';
describe('clampScore', () => {
it('returns the score unchanged when inside the range', () => {
expect(clampScore(50, 0, 100)).toBe(50);
});
it('clamps a score above the maximum', () => {
expect(clampScore(150, 0, 100)).toBe(100);
});
it('keeps a score exactly at the minimum boundary', () => {
expect(clampScore(0, 0, 100)).toBe(0);
});
});Summary
- A module is a file with its own scope;
export/import(named or default) is how code crosses between modules deliberately. package.jsonlists a project’s scripts and dependencies;dependenciesship with the running program,devDependenciesare needed only during development.package.json’s version ranges allow flexibility; the committed lockfile pins exact, reproducible versions — both are needed for a build to be reliably reproducible across contributors and CI.- A unit test checks one small piece of behavior in isolation, structured as Arrange (set up), Act (run the code), Assert (check the result).
- Testing boundary cases (zero, empty, the minimum, the maximum) catches the class of bug that a test of only the “obvious” case would miss.