Modules, Imports, and Testing

Modules and Files

This Lesson

Public and Private Definitions

i
pub type Status {
  Active
  Done
}

pub type Todo {
  Todo(title: String, status: Status)
}
i
pub fn display_status(status: Status) -> String {
  case status {
    Active -> "[ ]"
    Done -> "[x]"
  }
}

Opaque Types

i
pub opaque type Counter {
  Counter(value: Int)
}

pub fn new() -> Counter { Counter(0) }
pub fn increment(c: Counter) -> Counter { Counter(c.value + 1) }
pub fn decrement(c: Counter) -> Counter {
  case c.value > 0 {
    True -> Counter(c.value - 1)
    False -> c
  }
}
pub fn value(c: Counter) -> Int { c.value }

Organising by Layer

i
pub fn add_task(tasks: List(types.Todo), title: String) -> List(types.Todo) {
  [types.Todo(title, types.Active), ..tasks]
}
i
pub fn render(tasks: List(types.Todo)) -> String {
  tasks
  |> list.index_map(fn(task, i) {
    let num = int.to_string(i + 1) <> ". "
    let status = types.display_status(task.status)
    num <> status <> " " <> task.title
  })
  |> string.join("\n")
}

Testing with gleeunit

i
pub fn add_task_test() {
  let result = logic.add_task([], "write tests")
  result
  |> should.equal([types.Todo("write tests", types.Active)])
}

pub fn render_single_test() {
  let tasks = [types.Todo("learn Gleam", types.Active)]
  logic.render(tasks)
  |> should.equal("1. [ ] learn Gleam")
}

Type Aliases

i
pub type Filename = String
pub type Hash = String
pub type FileMap = dict.Dict(Hash, List(Filename))

Check Understanding

Why separate logic from IO?

Pure functions are straightforward to test: give them known inputs and check the outputs. Functions that print to the screen, read files, or make network requests require more setup to test reliably. By keeping logic in a module that imports no IO modules, you can test all the interesting behaviour with simple tests that do not touch the filesystem or terminal. This pattern is sometimes called "functional core, imperative shell".

What makes a good unit test?

A good unit test is fast, isolated, and specific, and repeatable. Fast: it should complete in milliseconds, with no network or disk access. Isolated: it depends only on the function under test and its inputs. Specific: when it fails, the failure message tells you exactly what went wrong. Repeatable: it gives the same result every time (which means it doesn't depend on the current time, a random number, or anything similar). Pure functions in the logic layer are natural candidates because they satisfy all three criteria automatically.

Exercises

Opaque counter (10 minutes)

Define pub opaque type Counter with new(), increment(), decrement(), and value(). Add a guard in decrement so the counter never goes below zero. Write three gleeunit tests covering each operation.

Logic layer tests (10 minutes)

Write one unit test for each of add_task, mark_done, and render. Each test must check a specific outcome.

Type alias clarity (5 minutes)

Add type Title = String and type TaskIndex = Int to types.gleam. Update the signatures in logic.gleam to use them. Confirm gleam build still succeeds.

Opaque stack (15 minutes)

Define pub opaque type Stack(a) in a new file src/modules/stack.gleam. Implement new() -> Stack(a), push(Stack(a), a) -> Stack(a), pop(Stack(a)) -> Result(#(a, Stack(a)), Nil), and size(Stack(a)) -> Int. pop should return Error(Nil) on an empty stack. Write three gleeunit tests: one for push/pop round-trip, one for popping an empty stack, and one for size after several pushes.

Filter active tasks (10 minutes)

Add a filter_active(tasks: List(types.Todo)) -> List(types.Todo) function to logic.gleam that returns only tasks whose status is Active. Write a gleeunit test that creates a list with both Active and Done tasks and confirms only the active ones are returned.