BeginnerModules, tooling, and first tests~35 min

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:

JavaScriptmath.js — a small module with two exports
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"
  }
}
dependencies versus devDependencies
FieldMeaningExample
dependenciesNeeded to run the finished programA date-formatting library the app actually calls
devDependenciesNeeded only during developmentA 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.

JavaScriptTesting a pure function with Vitest
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

Given math.js exports: export function add(a, b) { return a + b; } export default function multiply(a, b) { return a * b; } Which import is correct?
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.

Two contributors both run npm install on the same package.json at different times. Without a committed package-lock.json, what's true?
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.

  1. Hint 1

    A named export (no default keyword) is always imported inside curly braces, using its exact exported name.

  2. 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.

  1. Hint 1

    Math.max and Math.min compose to clamp a value: Math.min(Math.max(score, min), max).

  2. 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.json lists a project’s scripts and dependencies; dependencies ship with the running program, devDependencies are 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.