Psaní testů pro doménovou logiku často naráží na problém opakování. Když stejnou byznysovou invariantu testujeme na několika implementacích téhož rozhraní, končíme buď duplicitním kódem, nebo abstraktními base classami s komplikovanou hierarchií. JUnit 5 ve spojení s default metodami Java rozhraní nabízí elegantnější cestu.

Co jsou výchozí metody rozhraní?

Od Javy 8 mohou rozhraní obsahovat metody s tělem (modifikátor default). Třídy, které rozhraní implementují, tyto metody zdědí, aniž by je musely překrýt. JUnit 5 (Jupiter) tohoto mechanismu využívá k definování testovacích šablon přímo v rozhraní — a testovací engine je spouští stejně, jako by byly napsány v konkrétní testovací třídě.

Motivace z bankovní domény

Mějme rozhraní pro výpočet úrokové sazby:

public interface InterestRateCalculator {
    BigDecimal calculateRate(Account account);
}

A dvě implementace — standardní a zvýhodněnou:

public class StandardRateCalculator implements InterestRateCalculator { ... }
public class PremiumRateCalculator implements InterestRateCalculator { ... }

Obě implementace musí splňovat stejné invarianty: sazba nemůže být záporná, pro stejný účet musí být vracena stejná hodnota při opakovaném volání, sazba pro spořicí účet musí být vyšší než pro běžný. Psát tyto testy dvakrát (nebo třikrát) je zbytečné.

Řešení: testovací rozhraní s default metodami

public interface InterestRateCalculatorTest {

    InterestRateCalculator createCalculator();

    @Test
    default void rateMustNotBeNegative() {
        var calculator = createCalculator();
        var account = new Account(AccountType.SAVINGS, BigDecimal.ZERO);
        assertThat(calculator.calculateRate(account)).isGreaterThanOrEqualTo(BigDecimal.ZERO);
    }

    @Test
    default void rateMustBeConsistentForSameAccount() {
        var calculator = createCalculator();
        var account = new Account(AccountType.CHECKING, BigDecimal.valueOf(1000));
        var first = calculator.calculateRate(account);
        var second = calculator.calculateRate(account);
        assertThat(first).isEqualByComparingTo(second);
    }

    @Test
    default void savingsRateMustExceedCheckingRate() {
        var calculator = createCalculator();
        var savings = new Account(AccountType.SAVINGS, BigDecimal.valueOf(5000));
        var checking = new Account(AccountType.CHECKING, BigDecimal.valueOf(5000));
        assertThat(calculator.calculateRate(savings))
                .isGreaterThan(calculator.calculateRate(checking));
    }
}

Konkrétní testovací třída už jen dodá tovární metodu a případné specializace:

class StandardRateCalculatorTest implements InterestRateCalculatorTest {
    @Override
    public InterestRateCalculator createCalculator() {
        return new StandardRateCalculator();
    }

    // specifický test jen pro standardní kalkulačku
    @Test
    void standardRateForLowBalance() {
        var calculator = createCalculator();
        var account = new Account(AccountType.SAVINGS, BigDecimal.valueOf(100));
        assertThat(calculator.calculateRate(account))
                .isEqualByComparingTo("0.005");
    }
}

class PremiumRateCalculatorTest implements InterestRateCalculatorTest {
    @Override
    public InterestRateCalculator createCalculator() {
        return new PremiumRateCalculator();
    }
}

Jen implementací rozhraní InterestRateCalculatorTest získává PremiumRateCalculatorTest všechny testy ze sdíleného rozhraní — a JUnit 5 je spustí jako běžné @Test metody. Žádné dědičnosti, žádné base classy, žádná duplicita.

Parametrizace a adaptace kontextu

default metody můžou být i parametrizované — kombinace s @MethodSource nebo vlastními ArgumentsProvider je přímočará:

public interface TransactionFeeTest {

    TransactionFeeCalculator createCalculator();

    @ParameterizedTest
    @MethodSource("feeTestCases")
    default void feeShouldBeWithinLimits(Account account, BigDecimal expectedFee) {
        var fee = createCalculator().computeFee(account);
        assertThat(fee).isBetween(BigDecimal.ZERO, expectedFee);
    }

    static Stream<Arguments> feeTestCases() {
        return Stream.of(
            Arguments.of(new Account(AccountType.CHECKING, BigDecimal.valueOf(100)), BigDecimal.valueOf(5)),
            Arguments.of(new Account(AccountType.SAVINGS, BigDecimal.valueOf(1000)), BigDecimal.valueOf(2))
        );
    }
}

Předávání konfigurovatelných závislostí

Pokud testovaná logika vyžaduje další objekty (např. repository, klienta třetí strany), rozhraní může definovat výchozí implementace a testovací třída je podle potřeby překryje:

public interface TransferValidationTest {

    TransferValidator createValidator();

    default CurrencyConverter createCurrencyConverter() {
        return new FixedRateCurrencyConverter(BigDecimal.ONE); // testovací stub
    }

    @Test
    default void differentCurrencyTransferShouldConvertCorrectly() {
        var validator = createValidator();
        var converter = createCurrencyConverter();
        var transfer = new Transfer("CZK", "EUR", BigDecimal.valueOf(1000));
        var result = validator.validate(transfer, converter);
        assertThat(result.isValid()).isTrue();
    }
}

Srovnání s alternativními přístupy

Tabulka níže shrnuje hlavní rozdíly mezi přístupy:

Přístup Duplicita Flexibilita Čitelnost Údržba
Base class Nízká (dědí se) Omezená (jedna osa specializace) Střední Horší (křehká hierarchie)
Kopírování testů Vysoká Vysoká Nízká Špatná
default metody v rozhraní Nízká Vysoká (vícenásobná implementace) Vysoká Dobrá
Helper utilities Střední Vysoká Střední Dobrá

Ekvivalenty v jiných jazycích

Stejný pattern není omezen na Javu. Podobné mechanismy nabízí:

Kotlin

Kotlin podporuje default metody v rozhraních od verze 1.0 (zkompilují se do Java default metod), takže pattern funguje identicky. Kotlin navíc přidává extension functions, které lze použít k obohacení testovacích objektů bez nutnosti implementace rozhraní:

interface InterestRateCalculatorTest {
    fun createCalculator(): InterestRateCalculator

    @Test
    fun `rate must not be negative`() {
        val calculator = createCalculator()
        val account = Account(AccountType.SAVINGS, BigDecimal.ZERO)
        assertDoesNotThrow { calculator.calculateRate(account) }
    }
}

Extension funkce umožňují přidávat testovací helpery:

fun InterestRateCalculator.Companion.withZeroRate() = ...

C# / .NET

Od C# 8.0 obsahují rozhraní výchozí implementace metod:

public interface IInterestRateCalculatorTest {
    IInterestRateCalculator CreateCalculator();

    [Fact]
    void RateMustNotBeNegative() {
        var calculator = CreateCalculator();
        var account = new Account(AccountType.Savings, 0m);
        Assert.True(calculator.CalculateRate(account) >= 0);
    }
}

xUnit, NUnit i MSTest default metody rozhraní podporují. Pokud testovací třída implementuje dvě testovací rozhraní, získává testy z obou — což je v .NET ekosystému obzvlášť užitečné díky striktní jedné base class.

Python

Python nemá vestavěná rozhraní, ale protokolový polymorfismus a ABC (Abstract Base Classes) umožňují obdobný pattern. unittest.TestCase lze kombinovat s mixiny:

class InterestRateCalculatorTestMixin:
    def create_calculator(self):
        raise NotImplementedError

    def test_rate_not_negative(self):
        calc = self.create_calculator()
        account = Account(AccountType.SAVINGS, 0)
        self.assertGreaterEqual(calc.calculate_rate(account), 0)

class StandardRateCalculatorTest(InterestRateCalculatorTestMixin, TestCase):
    def create_calculator(self):
        return StandardRateCalculator()

Pytest navíc podporuje fixture parametrization na úrovni tříd:

@pytest.mark.parametrize("calc_cls", [StandardRateCalculator, PremiumRateCalculator])
class TestInterestRateCalculator:
    def test_rate_not_negative(self, calc_cls):
        calc = calc_cls()
        assert calc.calculate_rate(Account(AccountType.SAVINGS, 0)) >= 0

Ruby (RSpec)

Ruby díky dynamické povaze a shared examples v RSpec dosahuje stejného cíle jinak — sdílené příklady se definují samostatně a vkládají do konkrétních testů:

RSpec.shared_examples "interest rate calculator" do
  it "must not return negative rate" do
    rate = subject.calculate_rate(build(:account, :savings))
    expect(rate).to be >= 0
  end
end

RSpec.describe StandardRateCalculator do
  subject { described_class.new }
  it_behaves_like "interest rate calculator"
end

RSpec jde ještě dále — shared context umožňuje sdílet nejen testy, ale i setup (let, before).

Kdy tento pattern použít a kdy ne

Pattern je vhodný, když:

  • Existuje více implementací téhož kontraktu a všechny musí splňovat stejné invarianty.
  • Sdílená logika se týká čistě doménových pravidel, nikoli infrastruktury.
  • Chceme vynutit, aby každá nová implementace automaticky procházela existující sadou testů (smluvní testování — contract testing).

Naopak není vhodný, pokud:

  • Sdílená testovací logika vyžaduje složitou konfiguraci závislostí — pak je vhodnější base class nebo test utility.
  • Testy jsou převážně integrační s těžkou infrastrukturou.
  • Rozhraní se mění často a každá změna v default testech by znamenala revizi napříč všemi implementacemi.

Závěrem

Default metody rozhraní v JUnit 5 jsou praktický nástroj pro kompozitní testování. Umožňují definovat testovací kontrakty přímo vedle doménových rozhraní a vynucovat jejich splnění napříč implementacemi bez duplicity a bez křehké dědičnosti. Pattern není nový — obdobně ho řeší shared examples v Ruby, mixiny v Pythonu nebo extension funkce v Kotlinu — ale v Javě představuje nejčistší vyjádření myšlenky smluvního testování.

Zdroje a další čtení