Projeto Conta Bancária com Testes

Dinheiro não aceita "quase certo". Neste projeto você constrói uma conta bancária que deposita, saca e transfere com centavos exatos, recusa saques sem saldo com uma exceção própria e, o mais importante, vem com testes automáticos: pequenos programas que conferem a conta inteira com um único comando, mvn test. No fim, você vai quebrar o código de propósito e ver os testes pegarem o erro antes de qualquer cliente.

Você vai praticar
  • Montar um projeto Maven do zero, com o JUnit como dependência.
  • Representar dinheiro com BigDecimal (e entender por que não double).
  • Encapsulamento: o saldo só muda por depósito, saque e transferência.
  • Exceção própria checked: SaldoInsuficienteException.
  • Testes com JUnit 5: @Test, @BeforeEach, assertEquals, assertThrows.
  • Ler um relatório de teste que falhou e consertar o código.

O que vamos construir

Um projeto Maven com quatro arquivos de código e um pom.xml:

$ find . -type f | sort
./pom.xml
./src/main/java/br/com/ana/banco/App.java
./src/main/java/br/com/ana/banco/Conta.java
./src/main/java/br/com/ana/banco/SaldoInsuficienteException.java
./src/test/java/br/com/ana/banco/ContaTest.java

Sete testes que rodam em menos de um segundo e um programa de demonstração:

$ mvn test
…                                          « compilação: veja a saída completa na etapa 3
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running br.com.ana.banco.ContaTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.100 s -- in br.com.ana.banco.ContaTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
…
$ java -cp target/classes br.com.ana.banco.App
Ana: R$ 749.25
Bruno: R$ 250.75
Erro: Saldo insuficiente: saldo 250.75, pedido 300.00
  1. O projeto Maven (pastas e pom.xml).
  2. A conta e o dinheiro exato (BigDecimal, depósito).
  3. O primeiro teste (JUnit, mvn test).
  4. Saque e SaldoInsuficienteException (assertThrows).
  5. Transferência (um método que usa os outros dois).
  6. Uma falha de propósito (o teste vermelho e a correção).

Antes de começar

Leia antes Maven e Testes com JUnit: lá cada linha do pom.xml e cada anotação do JUnit é explicada com calma. Aqui vamos usar tudo junto. Na aula de JUnit a conta guardava o saldo num double; neste projeto vamos fazer do jeito certo para dinheiro.

As missões rodam sem Maven

O editor do guia não roda Maven nem JUnit. Por isso, as caixas Experimente usam a mesma Conta num arquivo só, com um main que confere os resultados "na mão". As saídas de mvn desta página foram rodadas de verdade, na máquina da Ana; repita-as no seu computador.


Etapa 1 · O projeto Maven

O Maven espera uma estrutura de pastas fixa: código em src/main/java, testes em src/test/java, e dentro delas as pastas do pacote. O nosso pacote é br.com.ana.banco (o "endereço" das classes, ao contrário do domínio de um site), que vira as pastas br/com/ana/banco:

$ mkdir -p java/banco
$ cd java/banco
$ mkdir -p src/main/java/br/com/ana/banco src/test/java/br/com/ana/banco

Na raiz do projeto, o pom.xml, igual ao da aula de JUnit:

<?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>br.com.ana</groupId>
    <artifactId>banco</artifactId>
    <version>1.0</version>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.13.4</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>
TrechoPara quê
groupId, artifactId, versionA identidade do projeto: br.com.ana:banco:1.0.
maven.compiler.release = 25Compila para o Java 25.
sourceEncoding = UTF-8Acentos nos textos ("Saldo insuficiente…") saem certos.
dependência junit-jupiter com <scope>test</scope>O JUnit 5. O escopo test diz que ele só é usado para compilar e rodar os testes, não vai junto com o programa.

Para conferir que o projeto está montado, uma classe mínima, App, com um main:

package br.com.ana.banco;

public class App {
    public static void main(String[] args) {
        System.out.println("Banco da Ana: projeto funcionando!");
    }
}
$ mvn compile
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------------< br.com.ana:banco >--------------------------
[INFO] Building banco 1.0
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] Loaded 24119 auto-discovered prefixes for remote repository central (prefixes-central.txt)
[INFO]
[INFO] --- resources:3.5.0:resources (default-resources) @ banco ---
[INFO] skip non existing resourceDirectory /home/ana/java/banco/src/main/resources
[INFO]
[INFO] --- compiler:3.16.0:compile (default-compile) @ banco ---
[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 1 source file with javac [debug release 25] to target/classes
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  2.556 s
[INFO] Finished at: 2026-10-16T18:53:15-03:00
[INFO] ------------------------------------------------------------------------
$ java -cp target/classes br.com.ana.banco.App
Banco da Ana: projeto funcionando!
mvn compile
Compila o código de src/main/java e põe os .class em target/classes. Na primeira vez, antes dessas linhas aparecem dezenas de Downloading from central…: o Maven baixando os plugins que usa. Depois fica tudo guardado em ~/.m2.
java -cp target/classes
-cp (classpath, "caminho das classes") diz onde procurar os .class.
br.com.ana.banco.App
O nome completo da classe, com o pacote na frente.

Etapa 2 · A conta e o dinheiro exato

Por que não guardar o saldo num double? Porque o double guarda números em binário, e a maioria dos centavos não tem representação exata em binário (como 1/3 não tem em decimal). Veja no JShell:

$ jshell
|  Welcome to JShell -- Version 25.0.4.1
|  For an introduction type: /help intro

jshell> 0.1 + 0.2
$1 ==> 0.30000000000000004

jshell> new java.math.BigDecimal("0.1").add(new java.math.BigDecimal("0.2"))
$2 ==> 0.3

Para um jogo, 0.30000000000000004 não faz diferença. Para um banco, depois de milhões de operações, faz. O BigDecimal guarda o número em decimal, dígito por dígito, e as contas saem exatas. A Conta, por enquanto só com depósito:

package br.com.ana.banco;

import java.math.BigDecimal;

public class Conta {
    private final String titular;
    private BigDecimal saldo = BigDecimal.ZERO;

    public Conta(String titular) {
        this.titular = titular;
    }

    public String getTitular() {
        return titular;
    }

    public BigDecimal getSaldo() {
        return saldo;
    }

    public void depositar(BigDecimal valor) {
        validar(valor);
        saldo = saldo.add(valor);
    }

    private static void validar(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("O valor deve ser positivo: " + valor);
        }
    }
}
TrechoO que faz
package br.com.ana.banco;Primeira linha de todo arquivo do projeto: o pacote, igual às pastas.
BigDecimal.ZEROUma constante pronta com o valor 0.
saldo = saldo.add(valor);BigDecimal não usa +: usa métodos (add, subtract, multiply). E ele é imutável: add devolve um número novo, que precisa ser guardado de volta em saldo. Só escrever saldo.add(valor); não muda nada.
valor.signum() <= 0signum ("sinal") devolve -1, 0 ou 1. Recusamos zero e negativos.
private static void validarUm método auxiliar só da classe; o depósito e (logo mais) o saque usam o mesmo.
Sem setSaldoO saldo só muda por operações bancárias (encapsulamento).

Um App para experimentar, que também mostra a conta com double e um depósito inválido:

package br.com.ana.banco;

import java.math.BigDecimal;

public class App {
    public static void main(String[] args) {
        Conta ana = new Conta("Ana");
        ana.depositar(new BigDecimal("0.10"));
        ana.depositar(new BigDecimal("0.20"));
        System.out.println(ana.getTitular() + ": R$ " + ana.getSaldo());

        double d = 0.10 + 0.20;
        System.out.println("Com double: " + d);

        ana.depositar(new BigDecimal("-5.00"));
    }
}
$ mvn -q compile
$ java -cp target/classes br.com.ana.banco.App
Ana: R$ 0.30
Com double: 0.30000000000000004
Exception in thread "main" java.lang.IllegalArgumentException: O valor deve ser positivo: -5.00
	at br.com.ana.banco.Conta.validar(Conta.java:28)
	at br.com.ana.banco.Conta.depositar(Conta.java:22)
	at br.com.ana.banco.App.main(App.java:15)

O -q (quiet, "quieto") faz o Maven só falar se der erro. Repare no 0.30, com duas casas: o BigDecimal lembra a escala (o número de casas decimais) dos valores que você usou. Isso vai importar na etapa 3.

Crie BigDecimal a partir de texto

new BigDecimal("0.1") é exatamente 0,1. Já new BigDecimal(0.1) recebe o double, que já nasceu errado, e mostra o erro todo: 0.1000000000000000055511151231257827021181583404541015625. Use sempre a versão com aspas (ou BigDecimal.valueOf(0.1)). Detalhes em BigDecimal e Dinheiro.

Missão (etapa 2): este programa soma centavos com double e mostra 0.30000000000000004. Reescreva com BigDecimal (comece em BigDecimal.ZERO e use add com new BigDecimal("0.10")) para mostrar Saldo: 0.30.

public class Main {
    public static void main(String[] args) {
        double saldo = 0;
        saldo = saldo + 0.10;
        saldo = saldo + 0.20;
        System.out.println("Saldo: " + saldo);
    }
}
import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        BigDecimal saldo = BigDecimal.ZERO;
        saldo = saldo.add(new BigDecimal("0.10"));
        saldo = saldo.add(new BigDecimal("0.20"));
        System.out.println("Saldo: " + saldo);
    }
}

Etapa 3 · O primeiro teste

Até agora, para saber se a conta funciona, você roda o App e olha a saída. Um teste automático faz isso por você: ele chama a conta e confere o resultado, sem ninguém precisar olhar. O arquivo fica em src/test/java, no mesmo pacote:

package br.com.ana.banco;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.math.BigDecimal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class ContaTest {
    private Conta ana;
    private Conta bruno;

    @BeforeEach
    void preparar() {
        ana = new Conta("Ana");
        bruno = new Conta("Bruno");
    }

    @Test
    void depositoAumentaOSaldo() {
        ana.depositar(new BigDecimal("100.50"));
        assertEquals(new BigDecimal("100.50"), ana.getSaldo());
    }

    @Test
    void centavosSomamSemErro() {
        ana.depositar(new BigDecimal("0.10"));
        ana.depositar(new BigDecimal("0.20"));
        assertEquals(new BigDecimal("0.30"), ana.getSaldo());
    }

    @Test
    void depositoNegativoEhRecusado() {
        assertThrows(IllegalArgumentException.class, () -> ana.depositar(new BigDecimal("-5.00")));
    }
}
import static …Assertions.assertEquals;
Um import static traz o método para usar sem o nome da classe: assertEquals(…) em vez de Assertions.assertEquals(…).
@BeforeEach void preparar()
Roda antes de cada teste: cada um começa com contas novas, zeradas, sem depender dos outros.
@Test
Marca um método como teste. O nome do método descreve o que se espera: depositoAumentaOSaldo.
assertEquals(esperado, obtido)
"Afirme que são iguais." Se não forem, o teste falha. Primeiro o esperado, depois o que o código produziu.
assertThrows(IllegalArgumentException.class, () -> …)
"Afirme que isto lança essa exceção." O () -> … é uma lambda: um pedaço de código que o JUnit roda dentro de uma proteção. Se a exceção não vier, o teste falha.
$ mvn test
[INFO] Scanning for projects...
[INFO]
[INFO] --------------------------< br.com.ana:banco >--------------------------
[INFO] Building banco 1.0
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] Loaded 24119 auto-discovered prefixes for remote repository central (prefixes-central.txt)
[INFO]
[INFO] --- resources:3.5.0:resources (default-resources) @ banco ---
[INFO] skip non existing resourceDirectory /home/ana/java/banco/src/main/resources
[INFO]
[INFO] --- compiler:3.16.0:compile (default-compile) @ banco ---
[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]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ banco ---
[INFO] skip non existing resourceDirectory /home/ana/java/banco/src/test/resources
[INFO]
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ banco ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 1 source file with javac [debug release 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.6.0:test (default-test) @ banco ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running br.com.ana.banco.ContaTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.098 s -- in br.com.ana.banco.ContaTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  3.864 s
[INFO] Finished at: 2026-10-16T18:53:29-03:00
[INFO] ------------------------------------------------------------------------

O mvn test compila o código (compile), compila os testes (testCompile, em target/test-classes) e roda os testes com o plugin surefire. Tests run: 3, Failures: 0: três testes, nenhum falhou. BUILD SUCCESS é a luz verde.

Quando dá errado: 100 não é igual a 100.00

A Ana escreveu o primeiro teste assim:

        ana.depositar(new BigDecimal("100.00"));
        assertEquals(new BigDecimal("100"), ana.getSaldo());
$ mvn test
…                                          « compilação igual à anterior
[INFO] Running br.com.ana.banco.ContaTest
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.120 s <<< FAILURE! -- in br.com.ana.banco.ContaTest
[ERROR] br.com.ana.banco.ContaTest.depositoAumentaOSaldo -- Time elapsed: 0.010 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <100> but was: <100.00>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
	at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
	at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:182)
	at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:177)
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1145)
	at br.com.ana.banco.ContaTest.depositoAumentaOSaldo(ContaTest.java:23)
	at java.base/java.lang.reflect.Method.invoke(Method.java:565)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)

[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR]   ContaTest.depositoAumentaOSaldo:23 expected: <100> but was: <100.00>
[INFO]
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[ERROR] BUILD FAILURE
…                                          « mais algumas linhas [ERROR] com dicas do Maven

"Esperado 100, mas era 100.00." Para o equals do BigDecimal, 100 (escala 0) e 100.00 (escala 2) são diferentes, e o assertEquals usa o equals. O código da conta está certo; o teste é que comparou do jeito errado. Duas saídas: escrever o esperado com a mesma escala (new BigDecimal("100.00"), como fizemos) ou comparar com compareTo, que ignora a escala: assertEquals(0, new BigDecimal("100").compareTo(ana.getSaldo())).

Missão (etapa 3): sem JUnit, o método confere faz o papel do assertEquals. Escreva o segundo teste: uma conta nova do Bruno, depósitos de 0.10 e 0.20, e confere("centavos somam sem erro", …) com o esperado 0.30.

import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        Conta ana = new Conta("Ana");
        ana.depositar(new BigDecimal("100.50"));
        confere("depósito aumenta o saldo", new BigDecimal("100.50"), ana.getSaldo());

        // segundo teste: uma conta nova, depósitos de 0.10 e 0.20, saldo esperado 0.30
    }

    static void confere(String teste, BigDecimal esperado, BigDecimal obtido) {
        if (esperado.equals(obtido)) {
            System.out.println("OK: " + teste);
        } else {
            System.out.println("FALHOU: " + teste + " (esperado " + esperado + ", obtido " + obtido + ")");
        }
    }
}

class Conta {
    private final String titular;
    private BigDecimal saldo = BigDecimal.ZERO;

    Conta(String titular) {
        this.titular = titular;
    }

    String getTitular() {
        return titular;
    }

    BigDecimal getSaldo() {
        return saldo;
    }

    void depositar(BigDecimal valor) {
        validar(valor);
        saldo = saldo.add(valor);
    }

    private static void validar(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("O valor deve ser positivo: " + valor);
        }
    }
}
import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        Conta ana = new Conta("Ana");
        ana.depositar(new BigDecimal("100.50"));
        confere("depósito aumenta o saldo", new BigDecimal("100.50"), ana.getSaldo());

        Conta bruno = new Conta("Bruno");
        bruno.depositar(new BigDecimal("0.10"));
        bruno.depositar(new BigDecimal("0.20"));
        confere("centavos somam sem erro", new BigDecimal("0.30"), bruno.getSaldo());
    }

    static void confere(String teste, BigDecimal esperado, BigDecimal obtido) {
        if (esperado.equals(obtido)) {
            System.out.println("OK: " + teste);
        } else {
            System.out.println("FALHOU: " + teste + " (esperado " + esperado + ", obtido " + obtido + ")");
        }
    }
}

class Conta {
    private final String titular;
    private BigDecimal saldo = BigDecimal.ZERO;

    Conta(String titular) {
        this.titular = titular;
    }

    String getTitular() {
        return titular;
    }

    BigDecimal getSaldo() {
        return saldo;
    }

    void depositar(BigDecimal valor) {
        validar(valor);
        saldo = saldo.add(valor);
    }

    private static void validar(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("O valor deve ser positivo: " + valor);
        }
    }
}

Etapa 4 · Saque e SaldoInsuficienteException

Sacar mais do que se tem não é um "erro de programação": é uma situação normal do banco, que quem chama o sacar precisa tratar (mostrar uma mensagem, oferecer um valor menor…). Por isso a exceção é checked (estende Exception):

package br.com.ana.banco;

import java.math.BigDecimal;

public class SaldoInsuficienteException extends Exception {
    public SaldoInsuficienteException(BigDecimal saldo, BigDecimal pedido) {
        super("Saldo insuficiente: saldo " + saldo + ", pedido " + pedido);
    }
}

E o método novo da Conta:

    public void sacar(BigDecimal valor) throws SaldoInsuficienteException {
        validar(valor);
        if (valor.compareTo(saldo) > 0) {
            throw new SaldoInsuficienteException(saldo, valor);
        }
        saldo = saldo.subtract(valor);
    }
throws SaldoInsuficienteException
Avisa que o saque pode falhar por falta de saldo. Quem chamar é obrigado a tratar ou repassar.
valor.compareTo(saldo) > 0
Com BigDecimal não se usa >. a.compareTo(b) devolve um número negativo (a menor), 0 (iguais) ou positivo (a maior). "> 0" = o valor pedido é maior que o saldo.
throw new SaldoInsuficienteException(saldo, valor)
Lança antes de mexer no saldo: se falhar, a conta fica como estava.
saldo.subtract(valor)
Subtração, de novo guardando o resultado.

Dois testes novos no ContaTest:

    @Test
    void saqueDiminuiOSaldo() throws SaldoInsuficienteException {
        ana.depositar(new BigDecimal("100.00"));
        ana.sacar(new BigDecimal("30.25"));
        assertEquals(new BigDecimal("69.75"), ana.getSaldo());
    }

    @Test
    void saqueMaiorQueOSaldoLancaExcecao() {
        ana.depositar(new BigDecimal("50.00"));
        SaldoInsuficienteException erro = assertThrows(SaldoInsuficienteException.class,
                () -> ana.sacar(new BigDecimal("80.00")));
        assertEquals("Saldo insuficiente: saldo 50.00, pedido 80.00", erro.getMessage());
        assertEquals(new BigDecimal("50.00"), ana.getSaldo());
    }

O primeiro teste tem throws SaldoInsuficienteException porque chama sacar sem try; se a exceção vier, o JUnit marca o teste como erro. O segundo confere três coisas: que a exceção veio, que a mensagem está certa e que o saldo não mudou.

$ mvn test
…
[INFO] Running br.com.ana.banco.ContaTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.112 s -- in br.com.ana.banco.ContaTest
…
[INFO] BUILD SUCCESS

E se ninguém tratar a exceção? Um App com throws no main deixa ela subir até a JVM:

$ java -cp target/classes br.com.ana.banco.App
Exception in thread "main" br.com.ana.banco.SaldoInsuficienteException: Saldo insuficiente: saldo 50.00, pedido 80.00
	at br.com.ana.banco.Conta.sacar(Conta.java:29)
	at br.com.ana.banco.App.main(App.java:9)

Repare no nome completo da sua exceção, com o pacote, logo depois de Exception in thread "main".

Missão (etapa 4): o método sacar está vazio: o saldo nunca muda. Escreva-o: valide o valor, lance SaldoInsuficienteException se o valor for maior que o saldo (use compareTo) e subtraia com subtract.

import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        Conta ana = new Conta("Ana");
        ana.depositar(new BigDecimal("100.00"));
        try {
            ana.sacar(new BigDecimal("30.25"));
            System.out.println("Saldo: " + ana.getSaldo());
            ana.sacar(new BigDecimal("80.00"));
            System.out.println("Saldo: " + ana.getSaldo());
        } catch (SaldoInsuficienteException e) {
            System.out.println("Erro: " + e.getMessage());
        }
    }
}

class Conta {
    private final String titular;
    private BigDecimal saldo = BigDecimal.ZERO;

    Conta(String titular) {
        this.titular = titular;
    }

    String getTitular() {
        return titular;
    }

    BigDecimal getSaldo() {
        return saldo;
    }

    void depositar(BigDecimal valor) {
        validar(valor);
        saldo = saldo.add(valor);
    }

    void sacar(BigDecimal valor) throws SaldoInsuficienteException {
        // confira o saldo e lance SaldoInsuficienteException se faltar dinheiro
    }

    private static void validar(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("O valor deve ser positivo: " + valor);
        }
    }
}

class SaldoInsuficienteException extends Exception {
    SaldoInsuficienteException(BigDecimal saldo, BigDecimal pedido) {
        super("Saldo insuficiente: saldo " + saldo + ", pedido " + pedido);
    }
}
import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        Conta ana = new Conta("Ana");
        ana.depositar(new BigDecimal("100.00"));
        try {
            ana.sacar(new BigDecimal("30.25"));
            System.out.println("Saldo: " + ana.getSaldo());
            ana.sacar(new BigDecimal("80.00"));
            System.out.println("Saldo: " + ana.getSaldo());
        } catch (SaldoInsuficienteException e) {
            System.out.println("Erro: " + e.getMessage());
        }
    }
}

class Conta {
    private final String titular;
    private BigDecimal saldo = BigDecimal.ZERO;

    Conta(String titular) {
        this.titular = titular;
    }

    String getTitular() {
        return titular;
    }

    BigDecimal getSaldo() {
        return saldo;
    }

    void depositar(BigDecimal valor) {
        validar(valor);
        saldo = saldo.add(valor);
    }

    void sacar(BigDecimal valor) throws SaldoInsuficienteException {
        validar(valor);
        if (valor.compareTo(saldo) > 0) {
            throw new SaldoInsuficienteException(saldo, valor);
        }
        saldo = saldo.subtract(valor);
    }

    private static void validar(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("O valor deve ser positivo: " + valor);
        }
    }
}

class SaldoInsuficienteException extends Exception {
    SaldoInsuficienteException(BigDecimal saldo, BigDecimal pedido) {
        super("Saldo insuficiente: saldo " + saldo + ", pedido " + pedido);
    }
}

Etapa 5 · Transferência

Transferir é sacar de uma conta e depositar na outra. Como os dois métodos já existem e já têm as regras, a transferência fica com duas linhas:

    public void transferir(Conta destino, BigDecimal valor) throws SaldoInsuficienteException {
        this.sacar(valor);
        destino.depositar(valor);
    }

A ordem importa: primeiro o saque, que pode falhar. Se falhar, a exceção interrompe o método antes do depósito, e nenhum dinheiro "aparece" no destino. Guarde isso: a etapa 6 é sobre essa ordem. Os testes novos:

    @Test
    void transferenciaMoveODinheiro() throws SaldoInsuficienteException {
        ana.depositar(new BigDecimal("1000.00"));
        ana.transferir(bruno, new BigDecimal("250.75"));
        assertEquals(new BigDecimal("749.25"), ana.getSaldo());
        assertEquals(new BigDecimal("250.75"), bruno.getSaldo());
    }

    @Test
    void transferenciaSemSaldoNaoMexeEmNenhumaConta() {
        ana.depositar(new BigDecimal("30.00"));
        assertThrows(SaldoInsuficienteException.class,
                () -> ana.transferir(bruno, new BigDecimal("100.00")));
        assertEquals(new BigDecimal("30.00"), ana.getSaldo());
        assertEquals(BigDecimal.ZERO, bruno.getSaldo());
    }

O App final (completo no fim da página) faz uma transferência e um saque recusado, agora tratado com try/catch:

$ mvn test
…
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
…
[INFO] BUILD SUCCESS
$ java -cp target/classes br.com.ana.banco.App
Ana: R$ 749.25
Bruno: R$ 250.75
Erro: Saldo insuficiente: saldo 250.75, pedido 300.00

Missão (etapa 5): escreva o transferir, reaproveitando sacar e depositar, para a Ana ficar com R$ 749.25 e o Bruno com R$ 250.75.

import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) throws SaldoInsuficienteException {
        Conta ana = new Conta("Ana");
        Conta bruno = new Conta("Bruno");
        ana.depositar(new BigDecimal("1000.00"));
        ana.transferir(bruno, new BigDecimal("250.75"));
        System.out.println(ana.getTitular() + ": R$ " + ana.getSaldo());
        System.out.println(bruno.getTitular() + ": R$ " + bruno.getSaldo());
    }
}

class Conta {
    private final String titular;
    private BigDecimal saldo = BigDecimal.ZERO;

    Conta(String titular) {
        this.titular = titular;
    }

    String getTitular() {
        return titular;
    }

    BigDecimal getSaldo() {
        return saldo;
    }

    void depositar(BigDecimal valor) {
        validar(valor);
        saldo = saldo.add(valor);
    }

    void sacar(BigDecimal valor) throws SaldoInsuficienteException {
        validar(valor);
        if (valor.compareTo(saldo) > 0) {
            throw new SaldoInsuficienteException(saldo, valor);
        }
        saldo = saldo.subtract(valor);
    }

    void transferir(Conta destino, BigDecimal valor) throws SaldoInsuficienteException {
        // tire o dinheiro desta conta e coloque no destino
    }

    private static void validar(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("O valor deve ser positivo: " + valor);
        }
    }
}

class SaldoInsuficienteException extends Exception {
    SaldoInsuficienteException(BigDecimal saldo, BigDecimal pedido) {
        super("Saldo insuficiente: saldo " + saldo + ", pedido " + pedido);
    }
}
import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) throws SaldoInsuficienteException {
        Conta ana = new Conta("Ana");
        Conta bruno = new Conta("Bruno");
        ana.depositar(new BigDecimal("1000.00"));
        ana.transferir(bruno, new BigDecimal("250.75"));
        System.out.println(ana.getTitular() + ": R$ " + ana.getSaldo());
        System.out.println(bruno.getTitular() + ": R$ " + bruno.getSaldo());
    }
}

class Conta {
    private final String titular;
    private BigDecimal saldo = BigDecimal.ZERO;

    Conta(String titular) {
        this.titular = titular;
    }

    String getTitular() {
        return titular;
    }

    BigDecimal getSaldo() {
        return saldo;
    }

    void depositar(BigDecimal valor) {
        validar(valor);
        saldo = saldo.add(valor);
    }

    void sacar(BigDecimal valor) throws SaldoInsuficienteException {
        validar(valor);
        if (valor.compareTo(saldo) > 0) {
            throw new SaldoInsuficienteException(saldo, valor);
        }
        saldo = saldo.subtract(valor);
    }

    void transferir(Conta destino, BigDecimal valor) throws SaldoInsuficienteException {
        this.sacar(valor);
        destino.depositar(valor);
    }

    private static void validar(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("O valor deve ser positivo: " + valor);
        }
    }
}

class SaldoInsuficienteException extends Exception {
    SaldoInsuficienteException(BigDecimal saldo, BigDecimal pedido) {
        super("Saldo insuficiente: saldo " + saldo + ", pedido " + pedido);
    }
}

Etapa 6 · Uma falha de propósito

Para ver os testes trabalhando, a Ana vai "melhorar" o código e, sem perceber, quebrá-lo. Ela pensa: "na transferência, é mais simpático o dinheiro chegar primeiro", e inverte as linhas:

Este código tem um erro de propósito.

    public void transferir(Conta destino, BigDecimal valor) throws SaldoInsuficienteException {
        destino.depositar(valor);
        this.sacar(valor);
    }

O código compila, e o App mostra exatamente a mesma saída de antes (a transferência dele tem saldo). Sem testes, o defeito iria para produção. Com testes:

$ mvn test
…                                          « compilação igual à da etapa 3
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running br.com.ana.banco.ContaTest
[ERROR] Tests run: 7, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.123 s <<< FAILURE! -- in br.com.ana.banco.ContaTest
[ERROR] br.com.ana.banco.ContaTest.transferenciaSemSaldoNaoMexeEmNenhumaConta -- Time elapsed: 0.068 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <0> but was: <100.00>
	at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
	at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
	at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
	at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:182)
	at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:177)
	at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1145)
	at br.com.ana.banco.ContaTest.transferenciaSemSaldoNaoMexeEmNenhumaConta(ContaTest.java:68)
	at java.base/java.lang.reflect.Method.invoke(Method.java:565)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)

[INFO]
[INFO] Results:
[INFO]
[ERROR] Failures:
[ERROR]   ContaTest.transferenciaSemSaldoNaoMexeEmNenhumaConta:68 expected: <0> but was: <100.00>
[INFO]
[ERROR] Tests run: 7, Failures: 1, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[ERROR] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  3.974 s
[INFO] Finished at: 2026-10-16T18:53:51-03:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.6.0:test (default-test) on project banco: There are test failures.
[ERROR]
[ERROR] See /home/ana/java/banco/target/surefire-reports for the individual test results.
[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
[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/MojoFailureException

Como ler esse relatório, de cima para baixo:

LinhaO que ela diz
Tests run: 7, Failures: 1Sete testes rodaram; um falhou. Os outros seis continuam verdes.
ContaTest.transferenciaSemSaldoNaoMexeEmNenhumaContaQual teste falhou. Um nome que descreve a regra ajuda muito agora: "transferência sem saldo não mexe em nenhuma conta".
expected: <0> but was: <100.00>O que deu errado: o saldo do Bruno devia ser 0, mas era 100.00.
ContaTest.java:68Onde: a linha 68 do teste, o assertEquals(BigDecimal.ZERO, bruno.getSaldo()).
BUILD FAILURE e as linhas [ERROR] do fimO Maven parou por causa dos testes. As dicas (-e, -X, surefire-reports) servem para casos mais difíceis; aqui a mensagem já disse tudo.

O diagnóstico: o Bruno recebeu R$ 100,00 antes de a Ana descobrir que não tinha saldo. O saque falhou, a exceção saiu do método… e o depósito já tinha acontecido. O banco criou dinheiro do nada. A correção é voltar à ordem certa (sacar primeiro):

    public void transferir(Conta destino, BigDecimal valor) throws SaldoInsuficienteException {
        this.sacar(valor);
        destino.depositar(valor);
    }
$ mvn test
…
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
…
[INFO] BUILD SUCCESS
A lição

O teste transferenciaSemSaldoNaoMexeEmNenhumaConta foi escrito na etapa 5, quando o código estava certo. Ele parecia inútil… até alguém mexer no código. Testes são uma rede de proteção para o futuro: rode mvn test antes de toda entrega. O mvn package, que gera o JAR, roda os testes e se recusa a empacotar com teste vermelho (veja Maven).

Missão (etapa 6): esta é a versão com o defeito, e o "teste caseiro" do main mostra FALHOU: Ana tem 30.00 e Bruno tem 100.00. Conserte o transferir para o teste mostrar OK.

import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        Conta ana = new Conta("Ana");
        Conta bruno = new Conta("Bruno");
        ana.depositar(new BigDecimal("30.00"));
        try {
            ana.transferir(bruno, new BigDecimal("100.00"));
            System.out.println("FALHOU: a transferência deveria ter sido recusada");
        } catch (SaldoInsuficienteException e) {
            System.out.println("Recusada: " + e.getMessage());
        }
        if (ana.getSaldo().equals(new BigDecimal("30.00")) && bruno.getSaldo().equals(BigDecimal.ZERO)) {
            System.out.println("OK: transferência sem saldo não mexe em nenhuma conta");
        } else {
            System.out.println("FALHOU: Ana tem " + ana.getSaldo() + " e Bruno tem " + bruno.getSaldo());
        }
    }
}

class Conta {
    private final String titular;
    private BigDecimal saldo = BigDecimal.ZERO;

    Conta(String titular) {
        this.titular = titular;
    }

    String getTitular() {
        return titular;
    }

    BigDecimal getSaldo() {
        return saldo;
    }

    void depositar(BigDecimal valor) {
        validar(valor);
        saldo = saldo.add(valor);
    }

    void sacar(BigDecimal valor) throws SaldoInsuficienteException {
        validar(valor);
        if (valor.compareTo(saldo) > 0) {
            throw new SaldoInsuficienteException(saldo, valor);
        }
        saldo = saldo.subtract(valor);
    }

    void transferir(Conta destino, BigDecimal valor) throws SaldoInsuficienteException {
        destino.depositar(valor);
        this.sacar(valor);
    }

    private static void validar(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("O valor deve ser positivo: " + valor);
        }
    }
}

class SaldoInsuficienteException extends Exception {
    SaldoInsuficienteException(BigDecimal saldo, BigDecimal pedido) {
        super("Saldo insuficiente: saldo " + saldo + ", pedido " + pedido);
    }
}
import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        Conta ana = new Conta("Ana");
        Conta bruno = new Conta("Bruno");
        ana.depositar(new BigDecimal("30.00"));
        try {
            ana.transferir(bruno, new BigDecimal("100.00"));
            System.out.println("FALHOU: a transferência deveria ter sido recusada");
        } catch (SaldoInsuficienteException e) {
            System.out.println("Recusada: " + e.getMessage());
        }
        if (ana.getSaldo().equals(new BigDecimal("30.00")) && bruno.getSaldo().equals(BigDecimal.ZERO)) {
            System.out.println("OK: transferência sem saldo não mexe em nenhuma conta");
        } else {
            System.out.println("FALHOU: Ana tem " + ana.getSaldo() + " e Bruno tem " + bruno.getSaldo());
        }
    }
}

class Conta {
    private final String titular;
    private BigDecimal saldo = BigDecimal.ZERO;

    Conta(String titular) {
        this.titular = titular;
    }

    String getTitular() {
        return titular;
    }

    BigDecimal getSaldo() {
        return saldo;
    }

    void depositar(BigDecimal valor) {
        validar(valor);
        saldo = saldo.add(valor);
    }

    void sacar(BigDecimal valor) throws SaldoInsuficienteException {
        validar(valor);
        if (valor.compareTo(saldo) > 0) {
            throw new SaldoInsuficienteException(saldo, valor);
        }
        saldo = saldo.subtract(valor);
    }

    void transferir(Conta destino, BigDecimal valor) throws SaldoInsuficienteException {
        this.sacar(valor);
        destino.depositar(valor);
    }

    private static void validar(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("O valor deve ser positivo: " + valor);
        }
    }
}

class SaldoInsuficienteException extends Exception {
    SaldoInsuficienteException(BigDecimal saldo, BigDecimal pedido) {
        super("Saldo insuficiente: saldo " + saldo + ", pedido " + pedido);
    }
}

O código completo

package br.com.ana.banco;

import java.math.BigDecimal;

public class Conta {
    private final String titular;
    private BigDecimal saldo = BigDecimal.ZERO;

    public Conta(String titular) {
        this.titular = titular;
    }

    public String getTitular() {
        return titular;
    }

    public BigDecimal getSaldo() {
        return saldo;
    }

    public void depositar(BigDecimal valor) {
        validar(valor);
        saldo = saldo.add(valor);
    }

    public void sacar(BigDecimal valor) throws SaldoInsuficienteException {
        validar(valor);
        if (valor.compareTo(saldo) > 0) {
            throw new SaldoInsuficienteException(saldo, valor);
        }
        saldo = saldo.subtract(valor);
    }

    public void transferir(Conta destino, BigDecimal valor) throws SaldoInsuficienteException {
        this.sacar(valor);
        destino.depositar(valor);
    }

    private static void validar(BigDecimal valor) {
        if (valor == null || valor.signum() <= 0) {
            throw new IllegalArgumentException("O valor deve ser positivo: " + valor);
        }
    }
}
package br.com.ana.banco;

import java.math.BigDecimal;

public class App {
    public static void main(String[] args) throws SaldoInsuficienteException {
        Conta ana = new Conta("Ana");
        Conta bruno = new Conta("Bruno");

        ana.depositar(new BigDecimal("1000.00"));
        ana.transferir(bruno, new BigDecimal("250.75"));
        System.out.println(ana.getTitular() + ": R$ " + ana.getSaldo());
        System.out.println(bruno.getTitular() + ": R$ " + bruno.getSaldo());

        try {
            bruno.sacar(new BigDecimal("300.00"));
        } catch (SaldoInsuficienteException e) {
            System.out.println("Erro: " + e.getMessage());
        }
    }
}
package br.com.ana.banco;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.math.BigDecimal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class ContaTest {
    private Conta ana;
    private Conta bruno;

    @BeforeEach
    void preparar() {
        ana = new Conta("Ana");
        bruno = new Conta("Bruno");
    }

    @Test
    void depositoAumentaOSaldo() {
        ana.depositar(new BigDecimal("100.50"));
        assertEquals(new BigDecimal("100.50"), ana.getSaldo());
    }

    @Test
    void centavosSomamSemErro() {
        ana.depositar(new BigDecimal("0.10"));
        ana.depositar(new BigDecimal("0.20"));
        assertEquals(new BigDecimal("0.30"), ana.getSaldo());
    }

    @Test
    void depositoNegativoEhRecusado() {
        assertThrows(IllegalArgumentException.class, () -> ana.depositar(new BigDecimal("-5.00")));
    }

    @Test
    void saqueDiminuiOSaldo() throws SaldoInsuficienteException {
        ana.depositar(new BigDecimal("100.00"));
        ana.sacar(new BigDecimal("30.25"));
        assertEquals(new BigDecimal("69.75"), ana.getSaldo());
    }

    @Test
    void saqueMaiorQueOSaldoLancaExcecao() {
        ana.depositar(new BigDecimal("50.00"));
        SaldoInsuficienteException erro = assertThrows(SaldoInsuficienteException.class,
                () -> ana.sacar(new BigDecimal("80.00")));
        assertEquals("Saldo insuficiente: saldo 50.00, pedido 80.00", erro.getMessage());
        assertEquals(new BigDecimal("50.00"), ana.getSaldo());
    }

    @Test
    void transferenciaMoveODinheiro() throws SaldoInsuficienteException {
        ana.depositar(new BigDecimal("1000.00"));
        ana.transferir(bruno, new BigDecimal("250.75"));
        assertEquals(new BigDecimal("749.25"), ana.getSaldo());
        assertEquals(new BigDecimal("250.75"), bruno.getSaldo());
    }

    @Test
    void transferenciaSemSaldoNaoMexeEmNenhumaConta() {
        ana.depositar(new BigDecimal("30.00"));
        assertThrows(SaldoInsuficienteException.class,
                () -> ana.transferir(bruno, new BigDecimal("100.00")));
        assertEquals(new BigDecimal("30.00"), ana.getSaldo());
        assertEquals(BigDecimal.ZERO, bruno.getSaldo());
    }
}

O pom.xml está na etapa 1 e a SaldoInsuficienteException na etapa 4.

Desafios extras

  1. Extrato. Guarde cada operação numa List<String> ("Depósito: 100.00", "Saque: 30.25") e crie getExtrato(). Teste que depois de um depósito e um saque o extrato tem 2 linhas.
  2. Transferir para si mesma. ana.transferir(ana, …) deveria ser recusado com IllegalArgumentException. Escreva o teste primeiro, veja falhar, depois implemente (isso é o TDD da aula de JUnit).
  3. Limite de cheque especial. Uma ContaEspecial extends Conta que pode ficar negativa até um limite (veja Herança). O que precisa mudar na Conta para isso ser possível?
  4. Duas casas sempre. Faça o depósito arredondar para 2 casas com valor.setScale(2, RoundingMode.HALF_EVEN) e teste que depositar(new BigDecimal("10")) deixa o saldo em 10.00.

Exercícios

1. Por que o saldo é BigDecimal e não double?

2. O que acontece com saldo.add(valor); sozinho, sem saldo = na frente?

3. O teste diz expected: <100> but was: <100.00>. O que houve?

4. Por que transferir chama sacar antes de depositar?

Complete o comando que compila e roda todos os testes do projeto:

mvn

Resumo

  • Projeto Maven: código em src/main/java, testes em src/test/java, JUnit no pom.xml com escopo test.
  • Dinheiro é BigDecimal criado de texto; as contas são métodos (add, subtract, compareTo) e o resultado precisa ser guardado.
  • SaldoInsuficienteException é checked: falta de saldo é uma situação esperada, que quem chama deve tratar.
  • @Test, @BeforeEach, assertEquals(esperado, obtido) e assertThrows cobrem quase tudo.
  • Um teste vermelho diz qual regra quebrou, o valor esperado, o obtido e a linha. Rode mvn test antes de entregar.

Próximo projeto: Tarefas com Maven, um programa de linha de comando empacotado num JAR.