Lesson 65Phase 8: Tools of the trade
Maven and Gradle: building real projects
Project layout, dependencies, the build lifecycle, and running tests from the command line with Maven or Gradle.
By BasithUpdated 50 min
- Maven
- pom.xml
- dependency
- lifecycle
- Gradle
- build.gradle
Every program in this course so far has been a few files that you compile with javac or run with one button. A real project is bigger. It has dozens or hundreds of classes, it uses libraries that other people wrote, and it has tests that must pass before anyone ships it. A build tool takes care of all of that from one short command, the same way on every computer. This lesson works through Maven step by step, then builds the same project with Gradle. Along the way you learn to read what both of them print, especially when a build fails.
- Explain what a build tool does that
javacandjavaalone do not. - Lay out a project the standard way, with
src/main/java,src/test/javaand atargetorbuildfolder for the output. - Read a
pom.xml: the project's coordinates, and the dependencies it downloads from Maven Central. - Run
mvn compile,mvn testandmvn package, and say which lifecycle phases each one runs. - Read a failed build and tell a compile error, a failing test and a missing dependency apart.
- Build the same project with Gradle, and explain why projects commit the
mvnwandgradlewwrapper scripts.
javac and java since your first Java program. In packages and imports you put classes in packages, compiled them into an out folder with -d, ran them with -cp, and met the NoClassDefFoundError that appears when a JAR is missing from the classpath. Testing with JUnit gave you a BankAccount with tests and a first look at mvn test, and Git and GitHub kept target/ and build/ out of your repository. This page puts those pieces together.What a build tool is for#
Here is the bank account from the testing lesson, grown into a small project. The class lives in the package com.example.bank, a Mainclass uses it, and a test class checks it. The account also stores its owner's name. People type names in all sorts of ways, so the account tidies the name with a library: code that someone else wrote, tested and shared, delivered as a JAR file. One method of Apache Commons Text turns ada LOVELACE into Ada Lovelace.
package com.example.bank; import org.apache.commons.text.WordUtils; public class BankAccount { private final String owner; private int balance; public BankAccount(String owner, int openingBalance) { this.owner = WordUtils.capitalizeFully(owner); this.balance = openingBalance; } public void deposit(int amount) { if (amount <= 0) { throw new IllegalArgumentException("Deposit must be positive"); } balance = balance + amount; } public void withdraw(int amount) { balance = balance - amount; } public String getOwner() { return owner; } public int getBalance() { return balance; }}package com.example.bank; public class Main { public static void main(String[] args) { BankAccount account = new BankAccount("ada LOVELACE", 100); account.deposit(50); account.withdraw(30); System.out.println(account.getOwner() + " has " + account.getBalance()); }}The test class, BankAccountTest, has the deposit and withdraw tests from the testing lesson and one more that checks the owner's name comes out as Ada Lovelace. Try to build the program with the tools you already know. From the project folder:
$ javac -d out src/main/java/com/example/bank/*.javasrc/main/java/com/example/bank/BankAccount.java:3: error: package org.apache.commons.text does not existimport org.apache.commons.text.WordUtils; ^src/main/java/com/example/bank/BankAccount.java:10: error: cannot find symbol this.owner = WordUtils.capitalizeFully(owner); ^ symbol: variable WordUtils location: class BankAccount2 errorsjavac has never heard of Commons Text. A library is not part of Java, so you fetch its JAR yourself and put it on the classpath. Download commons-text-1.15.0.jarfrom the library's website and try again:
$ javac -d out -cp commons-text-1.15.0.jar src/main/java/com/example/bank/*.java$ java -cp out:commons-text-1.15.0.jar com.example.bank.MainException in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils at org.apache.commons.text.WordUtils.capitalizeFully(WordUtils.java:250) at org.apache.commons.text.WordUtils.capitalizeFully(WordUtils.java:221) at com.example.bank.BankAccount.<init>(BankAccount.java:10) at com.example.bank.Main.main(Main.java:5)Caused by: java.lang.ClassNotFoundException: org.apache.commons.lang3.StringUtils at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580) at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:490) ... 4 moreIt compiles, and then the program crashes. Read the stack trace from the top: inside Commons Text, capitalizeFully needed a class called StringUtils from a second library, Apache Commons Lang, which you never asked for. Libraries are built on other libraries. Only after you track down the right version of that one as well does the program print its line:
$ java -cp out:commons-text-1.15.0.jar:commons-lang3-3.20.0.jar com.example.bank.MainAda Lovelace has 120That was one library. The tests need JUnit, which arrives as nine JAR files of its own. Every teammate repeats the hunt, some end up with different versions, and the commands keep getting longer.
A build tool is a program that does this work for you. You describe the project once, in a file: its name, the Java version, the libraries it needs. Then one command:
- compiles every source file, keeping the program and its tests apart;
- downloads the libraries you named, plus the libraries those need, in versions that fit together;
- runs the tests, and stops if one fails;
- packs the compiled classes into a JAR;
- and does all of it the same way on your laptop, on a teammate's computer and on the server that checks every change.
Key ideaA build tool reads a description of your project and turns the source code into tested, packaged code with one command, the same way on every computer.
The standard project layout#
A build tool needs to know where your code is. Instead of telling it, you put every file where it expects to find one. Here is the bank project, laid out for Maven:
bank/├── pom.xml└── src/ ├── main/ │ └── java/ │ └── com/ │ └── example/ │ └── bank/ │ ├── BankAccount.java │ └── Main.java └── test/ └── java/ └── com/ └── example/ └── bank/ └── BankAccountTest.javapom.xmlsits at the top and describes the project. The next section reads it line by line.src/main/javaholds the program's own code. Inside it the folders follow the package name, as in packages and imports:com.example.bankbecomescom/example/bank.src/test/javaholds the tests, in the same package as the class they test. Because the package matches, a test can use the class's package-private members, while the test code stays out of the finished program.src/main/resourcesandsrc/test/resources, when you need them, hold files that are not code, such as a settings file or test data. The build copies them next to the compiled classes.
When you build, Maven writes everything it produces into a new folder, target: compiled classes, test reports, the finished JAR. You never edit anything inside it, and you never commit it, because the next build makes it again from src. That is why the .gitignore in Git and GitHub lists target/. Gradle uses the same src folders and writes its output to build instead.
| Folder | What goes in it | Commit it? |
|---|---|---|
src/main/java | The program's source code, in package folders | Yes |
src/main/resources | Files the program reads, such as settings | Yes |
src/test/java | Test classes, in the same packages as the classes they test | Yes |
src/test/resources | Files that only the tests read | Yes |
target/ (Maven), build/ (Gradle) | Everything the build produces | No |
Agreeing on where things go, so that nobody has to configure it, is called convention over configuration. It pays off every time you open someone else's project: the code is in src/main/java and the tests are in src/test/java, whoever wrote it.
BankAccountTest is in the package com.example.bank. Where does its file go?The pom.xml: describing the project#
Maven reads one file, pom.xml. POM stands for Project Object Model, Maven's name for its description of a project. The file is written in XML, a text format where each value sits between an opening and a closing tag, such as <version>1.0-SNAPSHOT</version>, and tags can sit inside other tags. Here is the whole file for the bank project:
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>bank</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.release>25</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-text</artifactId> <version>1.15.0</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>6.1.3</version> <scope>test</scope> </dependency> </dependencies></project>- The first four lines say which kind of XML this is, so editors can check the file as you type. Every pom starts this way. Copy them and leave them alone.
modelVersionis the version of the POM format itself. It is4.0.0in every Maven 3 project.groupId,artifactIdandversionname the project. They are its coordinates, explained below.propertiesholds settings.maven.compiler.releaseset to25compiles the code for Java 25.project.build.sourceEncodingtells Maven to read the source files as UTF-8, so a name such asZoëin your code survives on every computer.dependencieslists the libraries the project needs, onedependencyblock each.
Coordinates: a project's address#
Every project, yours and every library on the internet, is named by three values called its coordinates:
groupIdsays who makes it. By convention it is a reversed domain name, like a package name:com.examplehere,org.apache.commonsfor Commons Text.artifactIdis the project's own name,bank. An artifact is a file that a build produces, usually a JAR.versionsays which release.1.0-SNAPSHOTmeans work in progress: a SNAPSHOT version is still changing, while a release version such as1.15.0never changes once it is published.
Written on one line with colons, Commons Text's coordinates are org.apache.commons:commons-text:1.15.0 and the bank project's are com.example:bank:1.0-SNAPSHOT. The JAR that Maven builds takes its name from the last two: bank-1.0-SNAPSHOT.jar.
Key ideaA groupId, an artifactId and a version make up a project's address. Give Maven a library's address, and it can find the library, download it and put it on the classpath.
mvn archetype:generate. Either way, you end up editing a file like the one above.Dependencies and Maven Central#
A dependency is another project that your project needs, named by its coordinates. The bank project has two, and they differ in their scope: which part of the build can use them. Commons Text has no scope line, so it gets the default, compile, which means the program needs it to compile and to run. JUnit has <scope>test</scope>: it is there only while the tests compile and run, so code in src/main/java cannot use it.
Maven downloads dependencies from Maven Central, a free public store of Java libraries at https://repo.maven.apache.org/maven2. You search it at central.sonatype.com, where every library has a page with its versions and the exact lines to paste into a pom or a Gradle file. Downloads are saved in your local repository, the folder .m2/repository in your home folder. Every project on your computer shares it, so each version of a library is downloaded once.
Here is the first mvn compile in the bank project, on a computer that has never run Maven. Most of the 200 download lines are cut; these are the ones for the two libraries:
$ mvn compile[INFO] Scanning for projects...[INFO][INFO] --------------------------< com.example:bank >--------------------------[INFO] Building bank 1.0-SNAPSHOT[INFO] from pom.xml[INFO] --------------------------------[ jar ]---------------------------------[INFO] Loaded 24138 auto-discovered prefixes for remote repository central (prefixes-central.txt)...Downloading from central: https://repo.maven.apache.org/maven2/org/apache/commons/commons-text/1.15.0/commons-text-1.15.0.pom...Downloaded from central: https://repo.maven.apache.org/maven2/org/apache/commons/commons-text/1.15.0/commons-text-1.15.0.pom (24 kB at 59 kB/s)...Downloading from central: https://repo.maven.apache.org/maven2/org/apache/commons/commons-lang3/3.20.0/commons-lang3-3.20.0.pom...Downloaded from central: https://repo.maven.apache.org/maven2/org/apache/commons/commons-lang3/3.20.0/commons-lang3-3.20.0.pom (33 kB at 256 kB/s)...Downloading from central: https://repo.maven.apache.org/maven2/org/apache/commons/commons-text/1.15.0/commons-text-1.15.0.jarDownloaded from central: https://repo.maven.apache.org/maven2/org/apache/commons/commons-text/1.15.0/commons-text-1.15.0.jar (265 kB at 652 kB/s)Downloading from central: https://repo.maven.apache.org/maven2/org/apache/commons/commons-lang3/3.20.0/commons-lang3-3.20.0.jarDownloaded from central: https://repo.maven.apache.org/maven2/org/apache/commons/commons-lang3/3.20.0/commons-lang3-3.20.0.jar (714 kB at 1.3 MB/s)...[INFO] --- compiler:3.16.0:compile (default-compile) @ bank ---...[INFO] Recompiling the module because of changed source code.[INFO] Compiling 2 source files with javac [debug release 25] to target/classes[INFO] ------------------------------------------------------------------------[INFO] BUILD SUCCESS[INFO] ------------------------------------------------------------------------[INFO] Total time: 10.179 s[INFO] Finished at: 2026-10-19T14:11:29+03:00[INFO] ------------------------------------------------------------------------Look at the addresses. The coordinates turn into folders: the dots of org.apache.commons become org/apache/commons, then come the artifact and the version. It is the same mirror-the-folders rule that packages follow.
Maven fetched Commons Lang without being asked. It downloaded Commons Text's own pom first (the .pom file), found Commons Lang listed there as a dependency, and fetched that too. A library you get because another library needs it is a transitive dependency. Most of the cut lines are Maven's own plugins, the pieces of Maven that do the actual work, such as the compiler plugin. They come from Maven Central as well, which is why the first build is slow and the ones after it are quick.
Seeing the whole tree#
mvn dependency:tree prints every library the project gets, and which library brought it:
$ mvn dependency:tree...[INFO] --- dependency:3.11.0:tree (default-cli) @ bank ---...[INFO] com.example:bank:jar:1.0-SNAPSHOT[INFO] +- org.apache.commons:commons-text:jar:1.15.0:compile[INFO] | \- org.apache.commons:commons-lang3:jar:3.20.0:compile[INFO] \- org.junit.jupiter:junit-jupiter:jar:6.1.3:test[INFO] +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test[INFO] | +- org.opentest4j:opentest4j:jar:1.3.0:test[INFO] | +- org.junit.platform:junit-platform-commons:jar:6.1.3:test[INFO] | +- org.apiguardian:apiguardian-api:jar:1.1.2:test[INFO] | \- org.jspecify:jspecify:jar:1.0.0:test[INFO] +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test[INFO] \- org.junit.jupiter:junit-jupiter-engine:jar:6.1.3:test[INFO] \- org.junit.platform:junit-platform-engine:jar:6.1.3:test[INFO] ------------------------------------------------------------------------[INFO] BUILD SUCCESSTwo dependencies in the pom became eleven JAR files. The +- and \- marks draw the tree: Commons Lang sits under Commons Text because Commons Text brought it in. The last word on each line is the scope. Everything under JUnit has the scope test too: a library that only the tests need brings its own libraries in only for the tests.
src/main/java?The lifecycle: compile, test, package#
You do not tell Maven how to build, step by step. You say how far you want to go, and Maven runs a fixed list of steps up to that point. The list is the lifecycle, and each step in it is a phase. These are the main phases, in order:
- 1
validatechecks that the pom is complete and correct. - 2
compilecompilessrc/main/javaintotarget/classes. - 3
testcompiles the tests and runs them. - 4
packagepacks the compiled classes into a JAR intarget. - 5
verifyruns any extra checks on the package. A plain project has none. - 6
installcopies the JAR into your local repository, so other projects on your computer can depend on it. - 7
deployuploads the JAR to a shared repository, for other people to use.
Key ideaAsking for a phase runs every phase before it, in order.
mvn packagecompiles the code, runs the tests, and only then builds the JAR.
Each phase's work is done by a plugin. A line such as --- compiler:3.16.0:compile (default-compile) @ bank --- reads: the compiler plugin, version 3.16.0, is running its goal (one job) called compile, for the project bank. Here is the real output of mvn package on the bank project, after one earlier build:
$ mvn package[INFO] Scanning for projects...[INFO][INFO] --------------------------< com.example:bank >--------------------------[INFO] Building bank 1.0-SNAPSHOT[INFO] from pom.xml[INFO] --------------------------------[ jar ]---------------------------------[INFO] Loaded 24138 auto-discovered prefixes for remote repository central (prefixes-central.txt)[INFO][INFO] --- resources:3.5.0:resources (default-resources) @ bank ---[INFO] skip non existing resourceDirectory /home/you/bank/src/main/resources[INFO][INFO] --- compiler:3.16.0:compile (default-compile) @ bank ---[INFO] Loaded 74 auto-discovered prefixes for remote repository apache.snapshots (prefixes-apache.snapshots.txt)[INFO] Nothing to compile - all classes are up to date.[INFO][INFO] --- resources:3.5.0:testResources (default-testResources) @ bank ---[INFO] skip non existing resourceDirectory /home/you/bank/src/test/resources[INFO][INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ bank ---[INFO] Nothing to compile - all classes are up to date.[INFO][INFO] --- surefire:3.6.0:test (default-test) @ bank ---[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider[INFO][INFO] -------------------------------------------------------[INFO] T E S T S[INFO] -------------------------------------------------------[INFO] Running com.example.bank.BankAccountTest[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.056 s -- in com.example.bank.BankAccountTest[INFO][INFO] Results:[INFO][INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0[INFO][INFO][INFO] --- jar:3.5.1:jar (default-jar) @ bank ---[INFO] Building jar: /home/you/bank/target/bank-1.0-SNAPSHOT.jar[INFO] ------------------------------------------------------------------------[INFO] BUILD SUCCESS[INFO] ------------------------------------------------------------------------[INFO] Total time: 6.016 s[INFO] Finished at: 2026-10-19T14:12:31+03:00[INFO] ------------------------------------------------------------------------Read it from top to bottom and you can watch the phases go by. The lines about auto-discovered prefixes are Maven's bookkeeping about which files each download site holds; skip them.
| Line in the output | Phase | What it did |
|---|---|---|
resources:resources | process-resources | Copies src/main/resources into target/classes. This project has none, so it skips. |
compiler:compile | compile | Compiles src/main/java into target/classes. Nothing had changed since the last build, so nothing needed compiling. |
resources:testResources | process-test-resources | Copies src/test/resources. Skipped here too. |
compiler:testCompile | test-compile | Compiles src/test/java into target/test-classes. |
surefire:test | test | Runs every test class with Surefire, Maven's test runner, and writes reports into target/surefire-reports. |
jar:jar | package | Packs target/classes into target/bank-1.0-SNAPSHOT.jar. |
The full lifecycle has more phases than the seven famous ones. The ones in between, such as process-resources and test-compile, do the preparation, and you rarely name them yourself.
mvn package. What is in target afterwards?-DskipTests. That tells Surefire not to run the tests, so the JAR gets built from code you know is broken. Fix the code or the test instead.Starting clean, and installing#
mvn clean deletes the whole target folder, so the next build starts from nothing:
$ mvn clean...[INFO] --- clean:3.5.0:clean (default-clean) @ bank ---[INFO] Deleting /home/you/bank/target[INFO] ------------------------------------------------------------------------[INFO] BUILD SUCCESSclean belongs to a separate lifecycle, so it only runs when you ask for it. mvn clean package asks for both: first clean, then every phase up to package. That is the usual command when you want to be sure nothing old is left lying in target.
mvn install runs everything up to install, which copies the JAR and the pom into your local repository, under com/example/bank/1.0-SNAPSHOT. Another project on your computer can then list com.example:bank:1.0-SNAPSHOT as a dependency. deploy does the same for a shared repository on a server, which a team or a company sets up.
Running the program#
The JAR holds only your own classes: list it with jar tf target/bank-1.0-SNAPSHOT.jar and you find BankAccount.class and Main.class, and no Commons Text. So running it with plain java fails the way it did by hand:
$ java -cp target/bank-1.0-SNAPSHOT.jar com.example.bank.MainException in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/text/WordUtils at com.example.bank.BankAccount.<init>(BankAccount.java:10) at com.example.bank.Main.main(Main.java:5)Caused by: java.lang.ClassNotFoundException: org.apache.commons.text.WordUtils at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580) at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:490) ... 2 moreLet the build tool start it instead, because it knows the whole classpath. Your editor's Run button does exactly that. In a terminal, Maven's Exec plugin runs a main class with the project's libraries on the classpath:
$ mvn exec:java -Dexec.mainClass=com.example.bank.Main...[INFO] --- exec:3.6.4:java (default-cli) @ bank ---[INFO] Loaded 50 auto-discovered prefixes for remote repository ow2-snapshot (prefixes-ow2-snapshot.txt)Ada Lovelace has 120[INFO] ------------------------------------------------------------------------[INFO] BUILD SUCCESSexec:java is a goal, not a phase, so nothing runs before it and it compiles nothing. Run mvn compile first, or put both in one command: mvn compile exec:java -Dexec.mainClass=com.example.bank.Main.
java -jar and a single file name, with its libraries included or listed, is the subject of packaging and shipping a Java app. Before that, logging adds a dependency of exactly this kind.Reading a failed build#
A failed build prints a lot, and most of it looks the same every time. Read it in this order:
- 1Go to the bottom.
BUILD FAILUREconfirms that it failed, and the line that startsFailed to execute goalnames what failed: the compiler, Surefire, or the download of a dependency. - 2Find the first
[ERROR]line above it that names a file, a test or a library. That line is the cause. - 3Skip the lines about
-e,-Xand[Help 1]. They appear under every failure and only offer more detail.
A compile error#
Someone mistyped balance as balanse in deposit:
$ mvn test...[INFO] --- compiler:3.16.0:compile (default-compile) @ bank ---[INFO] Loaded 74 auto-discovered prefixes for remote repository apache.snapshots (prefixes-apache.snapshots.txt)[INFO] Recompiling the module because of changed source code.[INFO] Compiling 2 source files with javac [debug release 25] to target/classes[INFO] -------------------------------------------------------------[ERROR] COMPILATION ERROR :[INFO] -------------------------------------------------------------[ERROR] /home/you/bank/src/main/java/com/example/bank/BankAccount.java:[18,19] cannot find symbol symbol: variable balanse location: class com.example.bank.BankAccount[INFO] 1 error[INFO] -------------------------------------------------------------[INFO] ------------------------------------------------------------------------[ERROR] BUILD FAILURE[INFO] ------------------------------------------------------------------------[INFO] Total time: 4.379 s[INFO] Finished at: 2026-10-19T14:14:00+03:00[INFO] ------------------------------------------------------------------------[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.16.0:compile (default-compile) on project bank: Compilation failure[ERROR] /home/you/bank/src/main/java/com/example/bank/BankAccount.java:[18,19] cannot find symbol[ERROR] symbol: variable balanse[ERROR] location: class com.example.bank.BankAccount[ERROR] -> [Help 1][ERROR][ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.[ERROR] Re-run Maven using the -X switch to enable full debug logging.[ERROR][ERROR] For more information about the errors and possible solutions, please read the following articles:[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureExceptionThe goal at the bottom is compiler:compile, so the mistake is in src/main/java; a mistake in a test fails at compiler:testCompile instead. The file and position appear twice: BankAccount.java:[18,19] means line 18, column 19. The message is javac's own, the cannot find symbol you met in reading errors and debugging. Maven runs javac for you, so a compile error reads the way it always did. Notice too that the tests never ran: a build that cannot compile stops at compile.
A failing test#
Next, a new rule: an account must refuse to pay out more than it holds. The test comes first, added to the end of BankAccountTest, and withdraw does not check anything yet:
@Test void withdrawRefusesMoreThanTheBalance() { BankAccount account = new BankAccount("Ada", 100); assertThrows(IllegalArgumentException.class, () -> account.withdraw(500)); }$ mvn test...[INFO] --- surefire:3.6.0:test (default-test) @ bank ---[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider[INFO][INFO] -------------------------------------------------------[INFO] T E S T S[INFO] -------------------------------------------------------[INFO] Running com.example.bank.BankAccountTest[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.104 s <<< FAILURE! -- in com.example.bank.BankAccountTest[ERROR] com.example.bank.BankAccountTest.withdrawRefusesMoreThanTheBalance -- Time elapsed: 0.055 s <<< FAILURE!org.opentest4j.AssertionFailedError: Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown. at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3234) at com.example.bank.BankAccountTest.withdrawRefusesMoreThanTheBalance(BankAccountTest.java:39) [INFO][INFO] Results:[INFO][ERROR] Failures:[ERROR] BankAccountTest.withdrawRefusesMoreThanTheBalance:39 Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.[INFO][ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0[INFO][INFO] ------------------------------------------------------------------------[ERROR] BUILD FAILURE[INFO] ------------------------------------------------------------------------[INFO] Total time: 4.573 s[INFO] Finished at: 2026-10-19T14:14:20+03:00[INFO] ------------------------------------------------------------------------[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.6.0:test (default-test) on project bank: There are test failures.[ERROR][ERROR] See /home/you/bank/target/surefire-reports for the individual test results....The summary under Results:names the test and the line where it failed (39), followed by JUnit's message: the test expected an IllegalArgumentException and nothing was thrown. Tests run: 5, Failures: 1 is the count. A failure means an assertion did not hold; an error, counted separately, means a test crashed with an exception nobody expected. The details for every test class are saved in target/surefire-reports, as the last lines say.
BankAccount and its tests are two files; here they share one snippet so the Run button can run the tests (it cannot download Commons Text, so this copy has no owner name). Change withdraw so that it throws an IllegalArgumentException with the message Not enough money when the amount is larger than the balance. Leave the tests alone. The last test checks that taking out the whole balance still works.A dependency that cannot be found#
Last, a typo in the pom: 1.51.0 instead of 1.15.0 as the Commons Text version.
$ mvn compile...Downloading from central: https://repo.maven.apache.org/maven2/org/apache/commons/commons-text/1.51.0/commons-text-1.51.0.pom[WARNING] The POM for org.apache.commons:commons-text:jar:1.51.0 is missing, no dependency information availableDownloading from central: https://repo.maven.apache.org/maven2/org/apache/commons/commons-text/1.51.0/commons-text-1.51.0.jar[INFO] ------------------------------------------------------------------------[ERROR] BUILD FAILURE[INFO] ------------------------------------------------------------------------[INFO] Total time: 4.199 s[INFO] Finished at: 2026-10-19T14:15:19+03:00[INFO] ------------------------------------------------------------------------[ERROR] Failed to execute goal on project bank: Could not resolve dependencies for project com.example:bank:jar:1.0-SNAPSHOT[ERROR] dependency: org.apache.commons:commons-text:jar:1.51.0 (compile)[ERROR] Could not find artifact org.apache.commons:commons-text:jar:1.51.0 in central (https://repo.maven.apache.org/maven2)...No plugin ran at all. Before the first phase starts, Maven must have every dependency, and Maven Central has no Commons Text 1.51.0. The [WARNING] line was the first sign: the pom for that version is missing. When you see Could not find artifact, check the coordinates and the version against the library's page on Maven Central. If they are right, check your internet connection, because a project's first build needs one.
| The bottom of the output says | What went wrong | Where to look |
|---|---|---|
maven-compiler-plugin ... :compile ... Compilation failure | A compile error in the program | The file and [line,column] in the COMPILATION ERROR block, under src/main/java |
maven-compiler-plugin ... :testCompile ... Compilation failure | A compile error in a test | The same block, under src/test/java |
maven-surefire-plugin ... There are test failures. | A test failed | The Results: summary, then target/surefire-reports |
Could not resolve dependencies | A library could not be downloaded | The coordinates in pom.xml, then your connection |
The same project in Gradle#
Gradle builds the same project from the same folders. Its build files are short programs instead of XML, written in Kotlin (build.gradle.kts) or in Groovy (build.gradle). Both languages describe the same build. Kotlin has been the default for new projects since Gradle 8.2, and this page uses it; you do not need to know Kotlin to read one. Two files take the place of pom.xml:
rootProject.name = "bank"plugins { application} group = "com.example"version = "1.0-SNAPSHOT" repositories { mavenCentral()} dependencies { implementation("org.apache.commons:commons-text:1.15.0") testImplementation("org.junit.jupiter:junit-jupiter:6.1.3") testRuntimeOnly("org.junit.platform:junit-platform-launcher")} java { toolchain { languageVersion = JavaLanguageVersion.of(25) }} application { mainClass = "com.example.bank.Main"} tasks.test { useJUnitPlatform()}plugins { application }: Gradle knows nothing about Java until a plugin teaches it. Theapplicationplugin brings everything for compiling, testing and making a JAR, plus aruntask for a program with amainmethod.groupandversionare two of the coordinates. The third, the name, isrootProject.nameinsettings.gradle.kts.repositories { mavenCentral() }says where to download from. Gradle uses Maven Central too, but only when the build file says so.implementationis Maven'scompilescope andtestImplementationis itstestscope. Each dependency is one string of coordinates,group:name:version.testRuntimeOnlyadds the JUnit Platform launcher, the part of JUnit that starts the tests. Since Gradle 9 you must list it yourself. It needs no version, because JUnit's own files tell Gradle which launcher goes with JUnit 6.1.3.- The
toolchainblock compiles and runs everything with Java 25. If Gradle finds no JDK 25 on your computer, it stops and says so. mainClasstells theruntask which class to start, anduseJUnitPlatform()makes thetesttask run JUnit tests.
Tasks instead of phases#
Gradle has no fixed lifecycle. Its work is split into tasks, and each task names the tasks it needs done first. Ask for test and Gradle works out the rest. In a terminal, a passing build says very little:
$ gradle clean test BUILD SUCCESSFUL in 18s4 actionable tasks: 4 executedConsider enabling configuration cache to speed up this build: https://docs.gradle.org/9.8.1/userguide/configuration_cache_enabling.htmlThe last line is a tip about a setting that makes later builds faster, and you can ignore it. To see the tasks, add --console=plain. This is the very first gradle test in the project, which also downloaded the libraries:
$ gradle test --console=plain> Task :compileJava> Task :processResources NO-SOURCE> Task :classes> Task :compileTestJava> Task :processTestResources NO-SOURCE> Task :testClasses> Task :test BUILD SUCCESSFUL in 1m 18s3 actionable tasks: 3 executedConsider enabling configuration cache to speed up this build: https://docs.gradle.org/9.8.1/userguide/configuration_cache_enabling.htmlThe tasks line up with Maven's phases: compileJava is compile, compileTestJava is test-compile, and test is test. NO-SOURCE means the task had nothing to work on, because there is no resources folder. Now run the same command again without changing a thing:
$ gradle test --console=plain> Task :compileJava UP-TO-DATE> Task :processResources NO-SOURCE> Task :classes UP-TO-DATE> Task :compileTestJava UP-TO-DATE> Task :processTestResources NO-SOURCE> Task :testClasses UP-TO-DATE> Task :test UP-TO-DATE BUILD SUCCESSFUL in 8s3 actionable tasks: 3 up-to-dateConsider enabling configuration cache to speed up this build: https://docs.gradle.org/9.8.1/userguide/configuration_cache_enabling.htmlEvery task is UP-TO-DATE. Gradle remembers what each task read and wrote, and skips a task when none of it has changed, the tests included. Change one file and only the tasks that depend on it run again. Maven skips compiling unchanged code too (the Nothing to compile lines earlier), but it runs every test on every build.
Running, and reading Gradle's failures#
The run task starts Main with the whole classpath, Commons Text included:
$ gradle run > Task :runAda Lovelace has 120 BUILD SUCCESSFUL in 9s2 actionable tasks: 1 executed, 1 up-to-dateConsider enabling configuration cache to speed up this build: https://docs.gradle.org/9.8.1/userguide/configuration_cache_enabling.htmlHere is the failing withdrawtest from before, run by Gradle. It names the test and the line, and puts JUnit's full message in a report you open in a browser:
$ gradle test > Task :test BankAccountTest > withdrawRefusesMoreThanTheBalance() FAILED org.opentest4j.AssertionFailedError at BankAccountTest.java:39 5 tests completed, 1 failed > Task :test FAILED FAILURE: Build failed with an exception. * What went wrong:Execution failed for task ':test'.> There were failing tests. See the report at: file:///home/you/bank/build/reports/tests/test/index.html * Try:> Run with --scan to get full insights from a Build Scan (powered by Develocity). BUILD FAILED in 19s3 actionable tasks: 1 executed, 2 up-to-dateGradle's failures follow one pattern. Under * What went wrong: each > line explains the line above it, so the last, most indented one is the real cause. This is what you get if you leave out the junit-platform-launcher line:
$ gradle test > Task :test FAILED Gradle Test Executor 7 FAILED org.gradle.api.internal.tasks.testing.TestSuiteExecutionException at SuiteTestDefinitionProcessor.java:45 Caused by: org.gradle.api.internal.tasks.testing.RequiresTestFrameworkTestDefinitionProcessor$TestFrameworkNotAvailableException at JUnitPlatformTestDefinitionProcessor.java:101 FAILURE: Build failed with an exception. * What went wrong:Execution failed for task ':test' (registered by plugin 'org.gradle.jvm-test-suite').> Test process encountered an unexpected problem. > Could not start Gradle Test Executor 7. > Failed to load JUnit Platform. Please ensure that all JUnit Platform dependencies are available on the test's runtime classpath, including the JUnit Platform launcher. * Try:> Check common problems https://docs.gradle.org/9.8.1/userguide/java_testing.html#sec:java_testing_troubleshooting.> Run with --stacktrace option to get the stack trace.> Run with --info or --debug option to get more log output.> Run with --scan to get full insights from a Build Scan (powered by Develocity).> Get more help at https://help.gradle.org. BUILD FAILED in 20s3 actionable tasks: 2 executed, 1 up-to-dateRead down the > lines: the test task failed because its test process hit a problem, because the process could not start, because JUnit Platform could not be loaded, and the last line names the missing piece, the launcher. Put the testRuntimeOnly line back and the tests run.
The wrapper: mvnw and gradlew#
One problem is left. Your pom works with the Maven on your computer, but a teammate may have an older Maven or none at all, and the build server may have a third version. The wrappersolves this. It is a small script, kept in the project, that runs one exact version of the build tool and downloads that version the first time it is needed. Maven's wrapper plugin adds one:
$ mvn wrapper:wrapper...[INFO] --- wrapper:3.3.4:wrapper (default-cli) @ bank ---...[INFO] Unpacked only-script type wrapper distribution org.apache.maven.wrapper:maven-wrapper-distribution:zip:only-script:3.3.4[INFO] Configuring .mvn/wrapper/maven-wrapper.properties to use Maven 3.10.0 and download from https://repo.maven.apache.org/maven2[INFO] ------------------------------------------------------------------------[INFO] BUILD SUCCESSIt adds three files: the scripts mvnw (for macOS and Linux) and mvnw.cmd (for Windows), and a settings file that names the version:
wrapperVersion=3.3.4distributionType=only-scriptdistributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zipFrom now on you type ./mvnw where you typed mvn: ./mvnw test, ./mvnw package. On Windows the command is mvnw.cmd test (in PowerShell, .\mvnw.cmd test). The first run quietly downloads Maven 3.10.0 into .m2/wrapper/dists in your home folder, and after that the output is exactly what mvn prints. To move the project to a newer Maven, change the version in distributionUrl and commit the change; everyone gets it with their next pull.
gradle wrapper does the same for Gradle, and gradle init adds the wrapper to every new project. It writes four files: gradlew, gradlew.bat, and in gradle/wrapper a small gradle-wrapper.jar that does the downloading and a gradle-wrapper.properties that names the version. When Grace from Git and GitHub clones the project onto a computer with a JDK and nothing else, her first build looks like this:
$ ./gradlew testFetching distribution.Downloading https://services.gradle.org/distributions/gradle-9.8.1-bin.zip..............10%..............20%...............30%..............40%...............50%..............60%...............70%..............80%...............90%..............100% Welcome to Gradle 9.8.1! Here are the highlights of this release: - Java 27 support - Maven mirror settings reuse - Linked problem locations in build output For more details see https://docs.gradle.org/9.8.1/release-notes.html Starting a Gradle Daemon (subsequent builds will be faster) BUILD SUCCESSFUL in 4m 27s3 actionable tasks: 3 executedConsider enabling configuration cache to speed up this build: https://docs.gradle.org/9.8.1/userguide/configuration_cache_enabling.htmlThe download happens once for each Gradle version, and the welcome message appears only that first time, so the next build takes seconds. Starting a Gradle Daemon appears whenever none is running yet. The daemon is a Gradle process that stays in the background between builds, so the next build starts faster.
This only works if the wrapper files are in the repository, so commit them. Gradle's documentation says to commit all four, the JAR included.
| File or folder | Commit it? | Why |
|---|---|---|
pom.xml, build.gradle.kts, settings.gradle.kts | Yes | They describe the build. |
mvnw, mvnw.cmd, .mvn/wrapper/ | Yes | Anyone with a JDK can build without installing Maven. |
gradlew, gradlew.bat, gradle/wrapper/ | Yes | Anyone with a JDK can build without installing Gradle. |
target/, build/, .gradle/ | No | The build makes them again from the source. .gradle/ is Gradle's working cache for the project, so add it to your .gitignore next to target/ and build/. |
Key ideaCommit the wrapper, and anyone with a JDK can build the project with
./mvnwor./gradlew, using exactly the tool version the project names.
mvnw and maven-wrapper.properties, but she has never installed Maven. What happens when she runs ./mvnw test?Maven or Gradle?#
Both tools build Java projects well, and most of this page applies to both: the src layout, coordinates, Maven Central, scopes, the wrapper. They differ in how you describe the build and in how much work they skip.
pom.xml, in XML.build.gradle.kts in Kotlin, or build.gradle in Groovy.target.build.mvnw.gradlew.You will meet both. Android apps are built with Gradle, and Spring Initializr at start.spring.io, where many server projects begin, offers either one. When you join a project, use whatever it already uses. When you start your own, either is a fine choice: Maven's fixed steps make it the easier one to learn first, and Gradle's skipping saves more time as a build grows.
bin folder to your PATH, or use a package manager such as Homebrew (brew install maven, brew install gradle) or SDKMAN (sdk install maven, sdk install gradle). Check it with mvn -v or gradle -v. As this page is written, Maven 3.10.0 is the current release and Maven 4 is in preview.Try it
Answer each one in your head or on paper first, then open the answer.
javac and java do not.org.junit.jupiter:junit-jupiter:6.1.3. Which part is the groupId, which the artifactId and which the version, and where does Maven save the JAR on your computer?mvn test, and is there a JAR in target afterwards?Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.16.0:testCompile (default-testCompile) on project bank: Compilation failure. Which folder holds the mistake?Failed to load JUnit Platform, and the message mentions the JUnit Platform launcher. What is missing from build.gradle.kts?Practice
Work through these on your own. Each one builds on this page.
mvn package, list the JAR with jar tf target/bank-1.0-SNAPSHOT.jar, and run mvn dependency:tree. Then find commons-text-1.15.0.jar inside .m2/repository in your home folder.BankAccount.java, a wrong number in an assertEquals, and a wrong version in the pom. For each, write down the single line of output that told you what was wrong, and which plugin or step failed.gradle test --console=plain twice, then change one line in BankAccount.java and run it a third time. Which tasks ran the third time, and which were UP-TO-DATE? Compare with running mvn test twice..gitignore that leaves out target/, build/ and .gradle/, clone it into a second folder as in Git and GitHub, and build the clone with ./mvnw test and ./gradlew test.Further reading#
- Maven in 5 Minutes, the official first steps with Maven
- Introduction to the Build Lifecycle, Maven's guide to phases, plugins and goals
- Building Java Projects, the Gradle user guide chapter on the Java plugin, dependencies and tests
- The Gradle Wrapper, which files it adds and why they belong in version control
Short guides on this topic
Quick answers to what people search for about it, with code to run.
Spotted a mistake, or a part that did not make sense? Report it by email