Build and Test a Scala Project with sbt and munit
Edited by Dev Kilaru, Senior Data Analyst. How we make our content
Introduction
Single files are fine for learning, but real Scala work happens in projects with several files, dependencies, and tests. sbt, the Scala Build Tool, compiles code, downloads libraries, runs tests, and packages applications. This lesson creates a small project, explains the files sbt expects, and writes unit tests with munit, a lightweight test library, so you can build the habit of testing from the start.
Prerequisites
- Scala and sbt installed through Coursier (first lesson)
- The language fundamentals from the previous lessons
Step-by-Step Guide
Step 1: Generate a project from a template
The official Scala 3 template creates a ready-to-build project with sbt's new command. It asks for a project name and writes the files into a new folder. Look at what it creates: a build.sbt file at the root, a project folder holding build tooling settings, and a src folder with main and test subfolders. This standard layout is the same in every sbt project, so any Scala developer can find their way around yours.
sbt new scala/scala3.g8
cd my-project
# my-project/
# build.sbt
# project/build.properties
# src/main/scala/Main.scala
# src/test/scala/...Step 2: Read build.sbt
The build.sbt file describes the project in Scala syntax. It sets the Scala version, the project name, and the libraries it depends on. A dependency has the form of an organization, a name, and a version, joined by percent signs. A double percent sign appends the Scala version to the library name so you get the build that matches your compiler. The Test marker after a dependency means it is only available to test code. Use the Scala version your template generates, or check scala-lang.org for the current long-term support release.
ThisBuild / scalaVersion := "3.3.4"
lazy val root = (project in file("."))
.settings(
name := "calibertek-demo",
libraryDependencies += "org.scalameta" %% "munit" % "1.0.0" % Test
)Step 3: Write some code worth testing
Put application code under src/main/scala. A good first target is pure logic with no input or output, since it needs no setup to test. Here is a small module for a shopping cart total that uses a case class and a method with a rule. Notice it avoids reading from the console or printing: functions that take values and return values are the easiest to test. Keep the side effects, such as printing, in a thin main function that calls this logic.
// src/main/scala/Cart.scala
case class Item(name: String, price: Double, qty: Int)
object Cart:
def total(items: List[Item], discountPercent: Double = 0): Double =
require(discountPercent >= 0 && discountPercent <= 100, "discount must be 0-100")
val subtotal = items.map(i => i.price * i.qty).sum
subtotal * (1 - discountPercent / 100)
@main def run(): Unit =
val items = List(Item("pen", 2.5, 4), Item("book", 12.0, 1))
println(Cart.total(items, discountPercent = 10)) // 19.8Step 4: Write tests with munit
Tests live under src/test/scala. With munit, a test is a class that extends FunSuite and contains named test blocks. The assertEquals method compares an expected and an actual value and prints a clear diff when they differ. Because floating point numbers can differ in the last digits, compare doubles with a tolerance using the three-argument form of assertEqualsDouble. To check that bad input is rejected, use intercept, which passes only if the block throws the given exception type.
// src/test/scala/CartSuite.scala
class CartSuite extends munit.FunSuite:
test("total of an empty cart is zero") {
assertEquals(Cart.total(Nil), 0.0)
}
test("total multiplies price by quantity") {
val items = List(Item("pen", 2.5, 4), Item("book", 12.0, 1))
assertEqualsDouble(Cart.total(items), 22.0, 0.001)
}
test("a discount reduces the total") {
val items = List(Item("book", 20.0, 1))
assertEqualsDouble(Cart.total(items, 25), 15.0, 0.001)
}
test("an invalid discount is rejected") {
intercept[IllegalArgumentException] {
Cart.total(Nil, discountPercent = 150)
}
}Step 5: Run sbt commands
Start sbt from the project root with the sbt command to open its interactive shell, which keeps the compiler warm so later commands are much faster than launching sbt each time. The compile command builds, test runs the suites, and run launches the main function. Prefixing a command with a tilde makes sbt watch the files and rerun it every time you save, which gives a fast feedback loop while you write code. The console command opens a REPL with your project's classes already on the classpath.
sbt compile
sbt test
sbt run
sbt "testOnly CartSuite"
# interactive shell:
sbt
> ~test # rerun tests on every save
> console # REPL with your code loadedStep 6: Add libraries and keep the project healthy
To use another library, add a line to libraryDependencies and reload sbt. Look up the exact coordinates in the library's documentation or on Maven Central. Commit build.sbt and the project folder to version control, but not the target directories, which hold generated output. Keep tests small and independent, and test the behavior you care about rather than the implementation. As projects grow, you can split into several modules in the same build, and later lessons on Spark will use exactly this setup to package jobs for a cluster.
// build.sbt: add a dependency, then run "reload" in the sbt shell
libraryDependencies += "com.lihaoyi" %% "upickle" % "4.0.2"
// .gitignore
// target/
// project/target/
// .bsp/FAQs
- What is the difference between sbt and Scala CLI?
- Scala CLI is ideal for single files, scripts, and small tools, with dependencies declared at the top of the file. sbt is the long-standing build tool for larger multi-file and multi-module projects. Both can compile and test Scala 3, and you can start with Scala CLI and move to sbt as a project grows.
- Why does sbt feel slow the first time?
- The first run downloads sbt itself, the Scala compiler, and your dependencies. These are cached afterwards. Using the interactive shell or the tilde watch mode avoids restarting the JVM for every command.
- Which test library should I use?
- munit is simple and gives clear failure messages, and ScalaTest and specs2 are also widely used. The ideas are the same: small named tests with assertions. Pick whichever your team uses, and learn the others when you need to.
Summary
You created a Scala 3 project with sbt, learned the layout and the role of build.sbt, wrote application code as testable pure functions, and tested it with munit using assertEquals, tolerance checks, and intercept. With these tools you can move from experiments to maintainable code. From here a natural next step is Apache Spark, whose Scala API builds directly on the collections, case classes, and functions you have practiced in this path.
Related tutorials
- Schedule and Debug Shell Scripts: cron, bash -x, and Portability
- Shell Scripting Project: A Backup Script with Rotation and a Log Report
- Writing Safe Bash Scripts: set -euo pipefail, getopts, and trap
- Shell Pipes, Redirection, grep, sed, and awk for Text Processing
- Bash Loops, Arrays, and Functions Explained
Comments
Log in or create a free account to join the discussion.