TestNG Annotations and Test Lifecycle

8 min read

You've been writing TestNG tests for four chapters now — @BeforeMethod, @Test, @AfterMethod. Time to understand the rest. TestNG defines ten lifecycle annotations that fire at different scopes (suite, test block, class, method) and run in a strict, predictable order. Knowing this order isn't trivia — it determines where your driver setup belongs, where shared state can safely live, and why "tests pass individually but fail in the suite" usually means you put something at the wrong level. This lesson walks the entire lifecycle, the practical placement rules for Selenium, and the @Test attributes that make individual test methods do more than just run.

The ten annotations

TestNG annotations come in pairs — Before* and After* — at four scopes:

public class TestLifecycleDemo {
 
    @BeforeSuite     public void beforeSuite()  { System.out.println("Before SUITE"); }
    @BeforeTest      public void beforeTest()   { System.out.println("Before TEST"); }
    @BeforeClass     public void beforeClass()  { System.out.println("Before CLASS"); }
    @BeforeMethod    public void beforeMethod() { System.out.println("Before METHOD"); }
 
    @Test public void testOne() { System.out.println("Test One"); }
    @Test public void testTwo() { System.out.println("Test Two"); }
 
    @AfterMethod     public void afterMethod()  { System.out.println("After METHOD"); }
    @AfterClass      public void afterClass()   { System.out.println("After CLASS"); }
    @AfterTest       public void afterTest()    { System.out.println("After TEST"); }
    @AfterSuite      public void afterSuite()   { System.out.println("After SUITE"); }
}

The execution order is mechanical. For the class above, the console prints:

Before SUITE
Before TEST
Before CLASS
Before METHOD
Test One
After METHOD
Before METHOD
Test Two
After METHOD
After CLASS
After TEST
After SUITE

Read it as nested scopes — suite wraps test wraps class wraps each method. Suite-level setup runs once across the entire run; method-level setup runs before every @Test. Two confusions worth pinning down on day one:

  • @BeforeTest is not "before a test method". It's before each <test> block in testng.xml. A <test> block is a curated group of classes — covered in lesson 2 of this chapter. If you don't have multiple <test> blocks, @BeforeTest runs once and behaves indistinguishably from @BeforeSuite.
  • @BeforeClass runs once per class, not once per @Test. State you set up in @BeforeClass is shared by every test in the class. That's a feature when you want speed; it's a bug when one test corrupts state the next test needs.

The lifecycle, visualised

Step 1 of 6

@BeforeSuite

Runs ONCE at the very start of the entire test run. Right place for read-only suite setup like loading config or starting an API client.

Where Selenium things actually go

For Selenium tests, the placement rules are clear:

public class HomePageTest {
 
    WebDriver driver;
    WebDriverWait wait;
 
    @BeforeMethod                 // fresh driver per @Test — full test isolation
    public void setup() {
        WebDriverManager.chromedriver().setup();
        driver = new ChromeDriver();
        wait = new WebDriverWait(driver, Duration.ofSeconds(10));
        driver.manage().window().maximize();
        driver.get("https://qa.codes");
    }
 
    @AfterMethod                  // ALWAYS quit — leaks browser processes if you forget
    public void teardown() {
        if (driver != null) driver.quit();
    }
}

Two design decisions you'll make in every project:

  1. @BeforeMethod (driver per test) vs @BeforeClass (driver per class). Per-method gives bulletproof isolation but is slow — each test pays a full browser-startup cost. Per-class is much faster but lets state leak between tests. Default to @BeforeMethod; switch to @BeforeClass only when speed is measurably more important than isolation.
  2. @BeforeSuite for things that genuinely cost real money to set up. A test database container started via Docker. A logged-in API client used as a setup-helper across the whole run. Don't put cheap things here just because "the suite runs once."

The @Test attributes worth knowing

@Test accepts attributes that change how a single test method behaves:

// Run in a specific order — lower number first. Useful when you accept ordering for speed.
@Test(priority = 1)
public void earlyTest() { ... }
 
// Skip — usually a temporary marker while a feature is unstable
@Test(enabled = false)
public void skippedForNow() { ... }
 
// Description appears in HTML reports — make it human
@Test(description = "Logs in with valid credentials and lands on the dashboard")
public void shouldLoginSuccessfully() { ... }
 
// Fail if the test takes longer than 5 seconds
@Test(timeOut = 5000)
public void shouldRespondQuickly() { ... }
 
// The test is EXPECTED to throw — fails if it doesn't
@Test(expectedExceptions = NoSuchElementException.class)
public void shouldThrowWhenElementMissing() { ... }
 
// Run the same test 3 times in a row — useful for flake hunting
@Test(invocationCount = 3)
public void shouldBeStableAcrossRetries() { ... }

description deserves a special mention — most teams skip it, then read CI failure reports that show only method names like testInvalidLogin and wonder what they actually verified. A one-sentence description makes the report self-documenting.

expectedExceptions is the cleanest way to express "this should throw." Don't write try { ... } catch (...) { /* swallow */ } and call it passing — let TestNG handle it.

A complete lifecycle test

A class that demonstrates every annotation, run end to end:

package com.mycompany.tests.tests;
 
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.testng.Assert;
import org.testng.annotations.*;
 
public class LifecycleDemoTest {
 
    WebDriver driver;
 
    @BeforeSuite
    public void beforeSuite() {
        System.out.println("[SUITE] Loading config, starting any session-wide services");
        WebDriverManager.chromedriver().setup();   // resolve driver once for the whole run
    }
 
    @BeforeClass
    public void beforeClass() {
        System.out.println("[CLASS] Logging into a test API, seeding shared data");
    }
 
    @BeforeMethod
    public void beforeMethod() {
        System.out.println("[METHOD] Fresh driver");
        driver = new ChromeDriver();
        driver.manage().window().maximize();
        driver.get("https://qa.codes");
    }
 
    @Test(priority = 1, description = "Asserts the page title contains qa.codes")
    public void shouldHaveQaCodesTitle() {
        Assert.assertTrue(driver.getTitle().contains("qa.codes"));
    }
 
    @Test(priority = 2, description = "Asserts the Learn nav link is visible")
    public void shouldHaveLearnLink() {
        Assert.assertTrue(driver.findElement(By.linkText("Learn")).isDisplayed());
    }
 
    @AfterMethod
    public void afterMethod() {
        System.out.println("[METHOD] Quitting driver");
        if (driver != null) driver.quit();
    }
 
    @AfterClass
    public void afterClass() {
        System.out.println("[CLASS] Tearing down class-level resources");
    }
 
    @AfterSuite
    public void afterSuite() {
        System.out.println("[SUITE] Reporting suite-level results");
    }
}

Run it. You'll see one [SUITE] line at the start and end, one [CLASS] at each end, and [METHOD] lines wrapping each @Test. Watching the order print live makes the lifecycle click in a way reading docs alone never does.

Comparison with JUnit and pytest

// JUnit 5 equivalents
@BeforeAll       // = @BeforeClass (must be static in JUnit)
@BeforeEach      // = @BeforeMethod
@AfterEach       // = @AfterMethod
@AfterAll        // = @AfterClass
# pytest — fixtures with scope
@pytest.fixture(scope="session")  # = @BeforeSuite
@pytest.fixture(scope="module")   # ~= @BeforeTest
@pytest.fixture(scope="class")    # = @BeforeClass
@pytest.fixture                   # = @BeforeMethod (default scope is function)

TestNG has the most granular built-in lifecycle of the three. JUnit is similar in spirit but lacks the @BeforeSuite / @BeforeTest distinction. pytest is the most flexible because fixtures are values that can be combined arbitrarily, but you give up some of TestNG's declarative simplicity in return.

The Selenium tool entry covers driver-level concerns; the TestNG cheat sheet lists every annotation and @Test attribute.

⚠️ Common mistakes

  • Putting driver creation in @BeforeClass "for speed" without thinking through state leaks. Tests share the browser session — cookies, local storage, and any state from test #1 are visible to test #2. The first time it bites you is when an admin-only test passes locally and fails in CI because a previous test logged in as a regular user.
  • Confusing @BeforeTest with @BeforeMethod. They sound similar; they're not. @BeforeTest runs at the <test> block level in testng.xml. If your suite has one <test> block, @BeforeTest runs once and you may think it's running per method. Then you split into multiple <test> blocks and the behaviour suddenly changes. Be deliberate about which you mean.
  • Not putting if (driver != null) driver.quit() in @AfterMethod. A failure in @BeforeMethod (e.g., WebDriverManager can't reach the network) leaves driver as null. The unguarded driver.quit() then NPEs in teardown — masking the real failure with a confusing secondary one. Always guard the quit.

🎯 Practice task

Drive the full lifecycle. 25–35 minutes.

  1. Add LifecycleDemoTest from this lesson to your project. Run it. Read the console output and confirm the order matches the diagram.
  2. Add a third test with priority = 0. Run again. Watch how priority overrides source order — the priority-0 test now runs first.
  3. Try expectedExceptions. Add a test that does driver.findElement(By.id("does-not-exist")) and is annotated @Test(expectedExceptions = NoSuchElementException.class). Run it. The test passes — because the exception was the expected outcome.
  4. @BeforeClass vs @BeforeMethod cost. Convert your @BeforeMethod/@AfterMethod driver setup to @BeforeClass/@AfterClass. Time the suite with time mvn test -Dtest=LifecycleDemoTest. Then convert back. The class-scope version is faster — but if your tests share state, it's a bug factory. Decide which trade-off fits your context.
  5. Add description to every @Test. Run mvn clean test, open target/surefire-reports/index.html, and read the report. The descriptions appear in the report — no more wondering what testFlow1 actually checked.
  6. Stretch — invocationCount for flake detection. Take any test you suspect might be flaky. Add @Test(invocationCount = 50). Run it. If even one of the 50 fails, the test is flaky. Bonus: add threadPoolSize = 5 to @Test and watch it run 50× across 5 threads — early preview of chapter 5's parallel-execution work.

Next lesson: testng.xml in depth. Suites, tests, classes, groups, dependencies — the configuration surface that turns a folder of test files into a controlled, repeatable, CI-runnable suite.

// tip to track lessons you complete and pick up where you left off across devices.