<ContentPage eyebrow="Tutorial" title="Organize and test" description="Extract a module and test the logic behind your CLI.">

<TutorialNavigation slug="tutorial-test-and-organize" />

Start with chapter 7's complete tool. It works, but commands, storage, and task logic now share one long file. We'll separate the reusable parts and put tests beside them. The command's behavior and your saved data will stay the same.

## Split the file at a useful boundary

Create `task_list.tea` next to `tasks.tea`. Move the `Task` struct, `TaskError`, and the functions from `format_task` through `complete_task` into it. This module owns task data, its presentation, and its storage format. Keep `help`, `usage`, `run`, and the outer catch block in `tasks.tea`.

Move the `std.string` and `std.parse` imports into the new module. Both files need `std.fs`: the module performs file operations, and the entry file names `fs.FsError` in its error handlers.

Mark the declarations used by the command file with `pub`: `Task`, `TaskError`, `show_tasks`, `load_tasks`, `save_tasks`, `add_task`, and `complete_task`. Leave helpers such as `encode_tasks` private.

In `tasks.tea`, import the local module:

```tea
use task_list from "./task_list"
```

This path is relative to the source file. Call exported functions through the alias, such as `task_list.load_tasks("tasks.txt")`, and qualify the error type as `task_list.TaskError`. The two complete files below show every changed call.

## Write a test beside the logic

Add `use assert from "std.assert"` to `task_list.tea`, then append a test block:

```tea
test "format a task"
  const task = Task(title: "Learn Tea", done: false)
  assert.eq(format_task(task, 1), "1. [ ] Learn Tea")
end
```

`test` names a check that Tea's test runner can discover. `assert.eq` fails the test if the two values differ. Because this test lives in the module, it can call the private `format_task` helper.

Add the other three tests from the complete module. They check that encoding and decoding agree, completing a task mutates the original collection without changing its length, and malformed data is rejected. These tests use in-memory data, so running them doesn't touch your `tasks.txt`.

## Run the tests

```bash
tea test "$PWD/task_list.tea"
```

You should see four passing tests: `format a task`, `save and restore task data`, `complete a task without renumbering`, and `reject malformed saved data`. The summary reports `4 tests: 4 passed, 0 failed`; timing values will vary.

<NoteCard title="Use the full test path">
  <code>$PWD</code> is your shell's current directory. The current Tea test
  command resolves relative input paths against the compiler's build checkout.
  Passing the full path makes this command work from your own project directory.
</NoteCard>

Now confirm that the refactored command still uses your saved tasks:

```bash
tea tasks.tea list --all
```

```text
1. [ ] Learn Tea
2. [x] Build a CLI
3. [x] Take a break
4. [ ] Write a test
```

## Complete checkpoint

You now have two source files. Copy both if you are resuming here; keep your existing data file. The first contains the CLI, and the second contains the task logic and its tests.

<details>
<summary>Complete tasks.tea for this chapter</summary>

**`tasks.tea`**

```tea checkpoint="examples/tutorial/tasks/08-test-and-organize/tasks.tea"
use args from "std.args"
use fs from "std.fs"
use task_list from "./task_list"

def help() -> Void
  @println("Usage: tasks [list [--all] | add \"title\" | done NUMBER | --help]")
  @println("Tasks are numbered from 1. Listing shows open tasks; --all includes completed tasks.")
end

def usage() -> Void
  @eprintln("tasks: invalid arguments; run tasks --help for usage")
  @exit(2)
end

def run() -> Void ! { fs.FsError, task_list.TaskError }
  var argv = args.all()

  if @len(argv) == 0
    argv = ["list"]
  end

  if argv[0] == "--help" && @len(argv) == 1
    help()
    return
  end

  if argv[0] == "list"
    if @len(argv) > 2 ||(@len(argv) == 2 && argv[1] != "--all")
      usage()
      return
    end

    const tasks = task_list.load_tasks("tasks.txt")
    task_list.show_tasks(tasks, @len(argv) == 2)
    return
  end

  if argv[0] == "add" && @len(argv) == 2
    const tasks = task_list.load_tasks("tasks.txt")
    task_list.add_task(tasks, argv[1])
    task_list.save_tasks("tasks.txt", tasks)
    @println(`Added task ${@len(tasks)}.`)
    return
  end

  if argv[0] == "done" && @len(argv) == 2
    const tasks = task_list.load_tasks("tasks.txt")

    if task_list.complete_task(tasks, argv[1])
      task_list.save_tasks("tasks.txt", tasks)
      @println(`Completed task ${argv[1]}.`)
    else
      @println(`Task ${argv[1]} is already complete.`)
    end

    return
  end

  usage()
end

try run() catch err
  case is fs.FsError.OperationFailed
    @eprintln(`tasks: cannot ${err.operation} '${err.path}': ${err.message}`)
    @exit(1)
  case is task_list.TaskError.InvalidData
    @eprintln(`tasks: invalid data on line ${err.line}; expected [ ] title or [x] title`)
    @exit(1)
  case is task_list.TaskError.InvalidTitle
    @eprintln("tasks: title must be nonempty and on one line")
    @exit(2)
  case is task_list.TaskError.InvalidNumber
    @eprintln(`tasks: '${err.input}' is not an existing task number; use list --all`)
    @exit(2)
  case _
    @eprintln("tasks: unexpected error")
    @exit(1)
end
```

</details>

<details>
<summary>Complete task_list.tea for this chapter</summary>

**`task_list.tea`**

```tea checkpoint="examples/tutorial/tasks/08-test-and-organize/task_list.tea"
use assert from "std.assert"
use fs from "std.fs"
use parse from "std.parse"
use string from "std.string"

pub struct Task {
  title: String
  done: Bool
}

def format_task(task: Task, number: Int) -> String
  const marker = if(task.done) "[x]" else "[ ]"
  `${number}. ${marker} ${task.title}`
end

pub def show_tasks(tasks: List[Task], show_all: Bool) -> Void
  if @len(tasks) == 0
    @println("No tasks yet.")
    return
  end

  var number = 1
  var shown = 0

  for task in tasks
    if show_all ||!task.done
      @println(format_task(task, number))
      shown = shown + 1
    end

    number = number + 1
  end

  if shown == 0
    @println("No open tasks.")
  end
end

pub error TaskError {
  InvalidData(line: Int)
  InvalidTitle
  InvalidNumber(input: String)
}

def valid_title(title: String) -> Bool
  const without_spaces = string.replace(title, " ", "")
  const without_tabs = string.replace(without_spaces, "\t", "")
  @len(without_tabs) > 0 &&!string.contains(title, "\n") &&!string.contains(title, "\r")
end

def decode_tasks(content: String) -> List[Task] ! TaskError
  var tasks: List[Task] = []
  var number = 1

  for line in string.lines(string.replace(content, "\r\n", "\n"))
    if @len(line) < 5 ||!(string.starts_with(line, "[ ] ") || string.starts_with(line, "[x] "))
      throw TaskError.InvalidData(number)
    end

    const title = line[4..@len(line)]

    if ! valid_title(title)
      throw TaskError.InvalidData(number)
    end

    @append(tasks, Task(title: title, done: string.starts_with(line, "[x] ")))
    number = number + 1
  end

  tasks
end

pub def load_tasks(file: String) -> List[Task] ! { fs.FsError, TaskError }
  if ! fs.exists(file)
    return[]
  end

  decode_tasks(fs.read_file(file))
end

def encode_tasks(tasks: List[Task]) -> String
  var content = ""

  for task in tasks
    const marker = if(task.done) "[x]" else "[ ]"
    content = content + `${marker} ${task.title}\n`
  end

  content
end

pub def save_tasks(file: String, tasks: List[Task]) -> Void ! fs.FsError
  fs.write_file_atomic(file, encode_tasks(tasks))
end

pub def add_task(tasks: List[Task], title: String) -> Void ! TaskError
  if ! valid_title(title)
    throw TaskError.InvalidTitle
  end

  @append(tasks, Task(title: title, done: false))
end

pub def complete_task(tasks: List[Task], input: String) -> Bool ! TaskError
  if @len(input) == 0 || @len(input) > 19
    throw TaskError.InvalidNumber(input)
  end

  var i = 0

  while i < @len(input)
    if ! string.contains("0123456789", input[i])
      throw TaskError.InvalidNumber(input)
    end

    i = i + 1
  end

  const number = try parse.try_int(input) catch err
    case is parse.ParseError.InvalidInt
      throw TaskError.InvalidNumber(err.input)
    case _
      throw TaskError.InvalidNumber(input)
  end

  if number < 1 || number > @len(tasks) || @to_string(number) != input
    throw TaskError.InvalidNumber(input)
  end

  const task = tasks[number - 1]

  if task.done
    return false
  end

  task.done = true
  true
end

test "format a task"
  const task = Task(title: "Learn Tea", done: false)
  assert.eq(format_task(task, 1), "1. [ ] Learn Tea")
end

test "save and restore task data"
  const tasks = [
    Task(title: "Learn Tea", done: false),
    Task(title: "Take a break", done: true)
  ]
  const restored = decode_tasks(encode_tasks(tasks))
  assert.eq(@len(restored), 2)
  assert.eq(restored[0].title, "Learn Tea")
  assert.eq(restored[1].done, true)
end

test "complete a task without renumbering"
  const tasks = [Task(title: "Learn Tea", done: false)]
  assert.eq(complete_task(tasks, "1"), true)
  assert.eq(tasks[0].done, true)
  assert.eq(complete_task(tasks, "1"), false)
  assert.eq(@len(tasks), 1)
end

test "reject malformed saved data"
  try decode_tasks("not a task\n") catch err
    case is TaskError.InvalidData
      assert.eq(err.line, 1)
      return
    case _
      assert.ok(false)
      return
  end
  assert.ok(false)
end
```

</details>

## Try it yourself

Add a test for formatting a completed task. Deliberately give the assertion the wrong expected marker, run the tests, then correct it.

<details>
<summary>One possible solution</summary>

```tea
test "format a completed task"
  const task = Task(title: "Take a break", done: true)
  assert.eq(format_task(task, 3), "3. [x] Take a break")
end
```

With this optional test included, the summary will report five tests. The main walkthrough has four.

</details>

You have a reusable module and an automated way to check its behavior. Next, we'll compile the tool into an executable.

Read more: [modules](/docs/modules), [testing](/docs/testing), [assertions](/reference/assert).

<TutorialNavigation slug="tutorial-test-and-organize" position="bottom" />

</ContentPage>
