TestNG Interview Questions and Answers

Last updated:

Check out 35 of the most common TestNG interview questions, then take an AI-powered practice interview

JavaTest AnnotationsData ProvidersParallel TestingReports
35+
Questions
14
Basic
14
Intermediate
7
Advanced
Q1

What does TestNG give a Java automation suite that plain JUnit 4 does not?

BasicFundamentals

Answer

TestNG, created by Cedric Beust, is a test orchestrator rather than just an assertion library. Compared with JUnit 4 it adds: annotations with no naming convention or static suite() method required; an external suite descriptor, testng.xml, that lets a QA lead change run scope without recompiling code; groups as first-class metadata so any method can be tagged smoke, regression or sanity and included or excluded at runtime; @DataProvider returning Object[][] or Iterator<Object[]> so data-driven runs stream rows instead of loading everything up front; dependsOnMethods and dependsOnGroups, which mark downstream tests SKIP rather than FAIL when a prerequisite breaks; a built-in parallel model with parallel set to methods, classes, tests or instances plus thread-count, with no external runner; and a deep listener SPI (ITestListener, IAnnotationTransformer, IRetryAnalyzer, IMethodInterceptor, IReporter) that lets you bolt on retries, screenshots and reporting without editing a single test. It also emits testng-failed.xml so you can rerun only what broke.

JUnit 4 needed custom runners plus third-party libraries for most of that. JUnit 5 has closed much of the gap, but hybrid Selenium frameworks built at Indian service accounts between 2015 and 2020 are overwhelmingly TestNG, which is why the skill still appears in job descriptions. Note what TestNG does not do: it has no mocking, no browser control and no HTTP client. Assertions live in org.testng.Assert, and the argument order is assertEquals(actual, expected), the reverse of the JUnit 4 habit, which is the single most common source of misleading failure messages for people switching over.

Key Points

  • testng.xml lets you change run scope without recompiling
  • Groups, data providers and dependsOn are built in, not add-ons
  • Parallel execution is native via parallel and thread-count
  • Listener SPI adds retries and reporting without touching tests
  • org.testng.Assert takes (actual, expected), the opposite of JUnit 4
💡 Pro Tip: If an interviewer asks what TestNG is, answer in terms of orchestration and reporting. Candidates who describe it only as an annotations library sound like they have never owned a suite in CI.
Q2

In what order do TestNG annotations execute across a suite, and where do @BeforeGroups and @AfterGroups fit?

BasicLifecycle

Answer

The canonical order is @BeforeSuite, @BeforeTest, @BeforeGroups, @BeforeClass, @BeforeMethod, @Test, @AfterMethod, @AfterClass, @AfterGroups, @AfterTest, @AfterSuite. Each has a different scope. @BeforeSuite runs once for the whole suite element in testng.xml. @BeforeTest runs once per test tag, not per test method, which is the most misread annotation in the framework. @BeforeGroups runs once before the first method belonging to any group listed in its value attribute, and @AfterGroups after the last one, which makes them useful for expensive per-feature setup such as seeding a payments sandbox. @BeforeClass runs once per class, @BeforeMethod before every single @Test method in that class. Inheritance matters: the superclass @BeforeMethod runs before the subclass @BeforeMethod, and @AfterMethod unwinds in reverse, superclass last.

Two config methods carrying the same annotation inside one class have no defined relative order, so if ordering matters you must chain them with dependsOnMethods rather than relying on alphabetical or declaration order, which TestNG never promised. When a configuration method throws, TestNG does not fail the dependent tests, it marks them SKIPPED and, by default, skips downstream configuration too. Interviewers usually follow up by asking what happens to @AfterMethod when the @Test fails: it still runs, and it receives the failure through an injected ITestResult parameter, which is exactly how screenshot-on-failure logic is usually implemented in older frameworks before listeners took over.

public class LifecycleDemo {

  @BeforeSuite
  public void beforeSuite() { System.out.println("1 beforeSuite"); }

  @BeforeTest
  public void beforeTest() { System.out.println("2 beforeTest (per <test> tag)"); }

  @BeforeGroups(groups = {"checkout"})
  public void beforeCheckoutGroup() { System.out.println("3 beforeGroups"); }

  @BeforeClass
  public void beforeClass() { System.out.println("4 beforeClass"); }

  @BeforeMethod
  public void beforeMethod() { System.out.println("5 beforeMethod"); }

  @Test(groups = {"checkout"})
  public void placeOrder() { System.out.println("6 test"); }

  @AfterMethod
  public void afterMethod(ITestResult result) {
    System.out.println("7 afterMethod, status=" + result.getStatus());
  }

  @AfterClass
  public void afterClass() { System.out.println("8 afterClass"); }

  @AfterGroups(groups = {"checkout"})
  public void afterCheckoutGroup() { System.out.println("9 afterGroups"); }

  @AfterTest
  public void afterTest() { System.out.println("10 afterTest"); }

  @AfterSuite
  public void afterSuite() { System.out.println("11 afterSuite"); }
}

Key Points

  • @BeforeTest is per <test> tag in XML, never per test method
  • @BeforeGroups fires once before the first method of that group
  • Superclass config runs first going in, last coming out
  • Same-annotation config methods in one class have no guaranteed order
Q3

What is the practical difference between @BeforeTest, @BeforeClass and @BeforeMethod?

BasicLifecycle

Answer

The confusion comes from the word test, which TestNG uses for the XML element, not for a test method. @BeforeTest runs once per test tag in testng.xml, regardless of how many classes that tag contains. If your test tag lists five page-object classes, @BeforeTest fires once, before any of the five. @BeforeClass runs once per class, before the first @Test method in that class. @BeforeMethod runs before every individual @Test method, so a class with twelve tests calls it twelve times. In a Selenium framework the mapping is usually: @BeforeSuite for reading config and starting a Grid or Docker container, @BeforeTest for browser-level setup when each test tag represents one browser, @BeforeClass for launching the WebDriver and logging in once per feature class, @BeforeMethod for navigating back to a known baseline URL and clearing cookies.

The trap is quitting the driver in @AfterMethod while creating it in @BeforeClass: your second test in the class then runs against a dead session and throws a SessionNotCreatedException or an invalid session id error, which looks like a flaky test but is a lifecycle bug. Another trap is putting login in @BeforeMethod for a suite of two hundred tests, which turns a six-minute run into forty minutes. Interviewers often present exactly this scenario and ask where you would move the login, and the correct answer usually involves @BeforeClass plus cookie or token injection rather than repeating the UI login.

<!-- testng.xml: @BeforeTest fires ONCE per <test> tag -->
<suite name="regression" verbose="1">
  <test name="chrome-run">
    <parameter name="browser" value="chrome"/>
    <classes>
      <class name="tests.LoginTests"/>
      <class name="tests.CheckoutTests"/>
    </classes>
  </test>
  <test name="firefox-run">
    <parameter name="browser" value="firefox"/>
    <classes>
      <class name="tests.LoginTests"/>
    </classes>
  </test>
</suite>

<!-- chrome-run:  beforeTest x1, beforeClass x2, beforeMethod x(all tests) -->
💡 Pro Tip: Say out loud that @BeforeTest maps to the <test> tag. Panels use this exact question to separate people who wrote the framework from people who only added test methods to it.
Q4

Walk through the structure of a testng.xml suite file and the attributes that actually matter.

BasicSuite XML

Answer

testng.xml is the runtime contract for your suite and it is what CI invokes. The root element is suite, which carries name, parallel, thread-count, verbose, preserve-order, configfailurepolicy, data-provider-thread-count and time-out. Inside it, one or more test elements each define a logical run unit and can carry their own parameters, groups and parallel setting, overriding the suite level.

Inside test you either list packages, or classes with class elements, and inside a class you can narrow to individual methods with include and exclude patterns, which accept regular expressions. Group filtering goes in a groups element containing run with include and exclude children. Listeners are declared in a listeners element with listener class-name entries, which is preferable to @Listeners on a class when you want them applied suite-wide.

The DTD line at the top should point at https://testng.org/testng-1.0.dtd; older frameworks still carry the http variant and some corporate proxies then stall the run while the parser tries to fetch it, so an offline DTD or the https URL is worth fixing. Practical advice: keep one thin testng.xml per pipeline stage (smoke.xml, regression.xml, nightly.xml) instead of one giant file with commented-out blocks, because commented XML is invisible to code review and is how coverage silently disappears. Also remember TestNG writes testng-failed.xml into the output directory after a run, and pointing Surefire at that file is the cheapest possible rerun-only-failures step in Jenkins or GitHub Actions.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="regression" parallel="classes" thread-count="6"
       configfailurepolicy="skip" data-provider-thread-count="4" verbose="2">

  <listeners>
    <listener class-name="listeners.ScreenshotListener"/>
    <listener class-name="listeners.RetryTransformer"/>
  </listeners>

  <parameter name="env" value="staging"/>

  <test name="checkout-suite" preserve-order="true">
    <groups>
      <run>
        <include name="smoke"/>
        <exclude name="quarantine"/>
      </run>
    </groups>
    <classes>
      <class name="tests.CartTests">
        <methods>
          <include name="addToCart.*"/>
          <exclude name="addToCartWithCoupon"/>
        </methods>
      </class>
      <class name="tests.PaymentTests"/>
    </classes>
  </test>
</suite>

Key Points

  • suite carries parallel, thread-count, configfailurepolicy, data-provider-thread-count
  • test tags can override suite-level parallel and parameters
  • include and exclude accept regular expressions on method names
  • Declare listeners in XML for suite-wide scope instead of @Listeners
  • Rerun failures by feeding testng-failed.xml back to Surefire
Q5

How do org.testng.Assert and SoftAssert differ, and when is each correct?

BasicAssertions

Answer

Assert is a hard assertion: the first failure throws an AssertionError and the rest of the method never executes, so the test is reported failed immediately and any code after that line, including cleanup you wrote inline, is skipped. SoftAssert, from org.testng.asserts.SoftAssert, collects failures instead of throwing, and only reports them when you call assertAll(). Forgetting assertAll() is the classic bug: every soft assertion silently passes and your suite reports green while the application is broken.

Some teams guard against this by calling assertAll() from an @AfterMethod, but that only works if the SoftAssert instance is reachable and per-method. Use hard assertions for preconditions that make the rest of the test meaningless, for example asserting that the dashboard loaded before validating fifteen widgets on it. Use soft assertions for independent field-level validations where you genuinely want all failures in one report, typically UI form validation or a JSON response with many fields.

Two more details interviewers probe. First, argument order in TestNG is assertEquals(actual, expected), the mirror image of JUnit and of AssertJ, so a wrongly ordered call produces a backwards failure message and wastes triage time. Second, SoftAssert holds mutable state, so a SoftAssert declared as an instance field of a test class shared across parallel methods produces cross-contaminated failures.

Create it inside the test method, or hold it in a ThreadLocal. Many teams now skip SoftAssert entirely in favour of AssertJ soft assertions or a per-test collector, but you should still know the TestNG semantics.

import org.testng.Assert;
import org.testng.asserts.SoftAssert;

public class ProfileTests {

  @Test
  public void profileFieldsAreCorrect() {
    Profile p = api.fetchProfile(42);

    // Hard: nothing below matters if the profile is null
    Assert.assertNotNull(p, "profile 42 was not returned");

    // Soft: report every wrong field in one go
    SoftAssert soft = new SoftAssert();          // per method, never a field
    soft.assertEquals(p.getCity(), "Bengaluru", "city");
    soft.assertEquals(p.getPlan(), "GOLD", "plan");
    soft.assertTrue(p.isVerified(), "verified flag");
    soft.assertAll();                            // without this, all of it passes
  }
}
💡 Pro Tip: If you write SoftAssert as a class field to avoid repeating the constructor, you have just created a shared-state bug that only shows up when someone enables parallel execution.
Q6

How do TestNG groups work, and how do you run only the smoke group from CI?

BasicGroups

Answer

Groups are free-form string tags attached to test methods through the groups attribute of @Test. A method can belong to several groups, and a class-level @Test annotation propagates its groups to every public method in the class. At runtime you filter with the groups element in testng.xml, which contains a run element with include and exclude children, or from Maven with the Surefire groups and excludedgroups properties.

Exclusion always wins over inclusion, which is what makes a quarantine group work: tag a flaky test with quarantine, exclude quarantine at suite level, and the test stops blocking the pipeline without being deleted or commented out. Groups also support definitions, where you declare a meta-group in the define element that expands into several other groups, useful for building a nightly group out of regression plus integration. The gotcha that catches people: configuration methods are filtered by groups too.

If your @BeforeMethod carries no groups attribute and you run only the smoke group, TestNG still runs it, but if you annotate the config method with groups equal to regression and then run only smoke, the setup silently does not execute and every test fails with a null pointer on the driver. The fix is alwaysRun set to true on configuration methods, which forces them to run regardless of group filtering. Interviewers like to describe exactly that symptom and ask you to diagnose it.

public class SearchTests {

  @BeforeMethod(alwaysRun = true)   // runs no matter which group is selected
  public void setUp() { driver = DriverFactory.get(); }

  @Test(groups = {"smoke", "regression"})
  public void searchReturnsResults() { }

  @Test(groups = {"regression"})
  public void searchHandlesUnicodeQuery() { }

  @Test(groups = {"quarantine"})   // known flaky, excluded at suite level
  public void searchAutosuggestLatency() { }
}

<!-- testng.xml -->
<groups>
  <define name="nightly">
    <include name="regression"/>
    <include name="integration"/>
  </define>
  <run>
    <include name="smoke"/>
    <exclude name="quarantine"/>
  </run>
</groups>

<!-- or from CI, no XML edit needed -->
<!-- mvn test -Dgroups=smoke -DexcludedGroups=quarantine -->

Key Points

  • groups attribute on @Test; class-level @Test propagates to all public methods
  • Exclusion beats inclusion, which is what makes a quarantine group work
  • define builds meta-groups out of existing groups
  • Always set alwaysRun=true on configuration methods or group runs break
Q7

How does @DataProvider work, and what are the return types and wiring options?

BasicData Providers

Answer

A data provider is a method annotated with @DataProvider that returns either Object[][] or an Iterator<Object[]>. TestNG invokes the linked test method once per row, passing the row elements as method arguments in order. You link them with @Test(dataProvider = "name"); if the provider lives in a different class you also need dataProviderClass, and that provider method must be static unless the class has a no-argument constructor.

If you omit name on the annotation, the provider name defaults to the method name. Object[][] is the simple case and is fine for tens of rows. Iterator<Object[]> is the lazy case and is what you want when rows come from a large CSV, an Excel sheet read through Apache POI, or a database cursor, because TestNG pulls rows one at a time rather than materialising the whole matrix in heap.

A provider can itself accept parameters: declare a java.lang.reflect.Method argument to see which test method is asking, so one provider can serve several tests with different data, or declare an ITestContext argument to read suite parameters such as the target environment. Row count discipline matters more than people expect. Each row becomes a separate ITestResult in the report, so a provider with fifty thousand rows produces fifty thousand result objects and an unreadable HTML report. Beyond a few hundred rows, the correct answer in an interview is that data volume belongs in a single test that loops internally, or in a data-layer test, not in the TestNG report.

public class LoginDataTests {

  @DataProvider(name = "credentials")
  public Object[][] credentials() {
    return new Object[][] {
      { "valid@goodspace.ai", "Correct#123", true },
      { "valid@goodspace.ai", "wrong",       false },
      { "unknown@x.com",      "Correct#123", false },
    };
  }

  // Lazy provider: rows are pulled one at a time from a CSV
  @DataProvider(name = "bulkUsers")
  public Iterator<Object[]> bulkUsers() {
    return CsvRows.of("src/test/resources/users.csv").iterator();
  }

  // The provider can see which test asked for data
  @DataProvider(name = "byMethod")
  public Object[][] byMethod(Method m, ITestContext ctx) {
    return TestData.forMethod(m.getName(), ctx.getCurrentXmlTest().getParameter("env"));
  }

  @Test(dataProvider = "credentials")
  public void login(String user, String pass, boolean expectSuccess) {
    Assert.assertEquals(loginPage.login(user, pass), expectSuccess);
  }

  @Test(dataProvider = "bulkUsers", dataProviderClass = SharedProviders.class)
  public void createUser(String name, String email) { }
}

Key Points

  • Return Object[][] for small sets, Iterator<Object[]> for large or streamed data
  • dataProviderClass lets providers live in a shared class
  • Providers can accept Method and ITestContext parameters
  • Every row becomes its own ITestResult, so huge providers wreck reports and heap
Q8

How do @Parameters and @Optional pass values from testng.xml into test code?

BasicParameters

Answer

@Parameters reads named values declared with the parameter element in testng.xml and injects them as method arguments. It works on configuration methods, test methods and constructors. Parameters declared at suite level are visible to every test tag; parameters declared inside a test tag override the suite value for that tag only, which is precisely how the classic cross-browser suite is built: one test tag per browser, each carrying its own browser parameter, all pointing at the same classes. @Optional supplies a fallback when the parameter is absent, and without it TestNG throws a configuration exception rather than passing null, so a run started directly from the IDE without XML fails loudly.

This is a feature, not a bug, but it surprises people who run individual tests from IntelliJ. Values are always strings, so any non-string parameter needs manual parsing, and TestNG does not read system properties into parameters automatically. The usual production pattern is to layer them: read System.getProperty first so CI can pass -Denv=staging, fall back to the XML parameter, then fall back to a properties file checked into the repo for local runs.

That layering question comes up often because Jenkins and GitHub Actions jobs need to override values without editing XML. Compared with @DataProvider, @Parameters is for environment and configuration data that is fixed for a run, while data providers are for test data that varies per invocation. Mixing the two on one method is legal only if the data provider supplies the trailing arguments and parameters the leading ones.

public class CrossBrowserTests {

  private WebDriver driver;

  @BeforeMethod
  @Parameters({"browser", "gridUrl"})
  public void setUp(String browser, @Optional("http://localhost:4444") String gridUrl)
      throws MalformedURLException {
    // CI override wins, then testng.xml, then the default
    String target = System.getProperty("browser", browser);
    driver = new RemoteWebDriver(new URL(gridUrl), Capabilities.of(target));
  }

  @Test
  public void homePageLoads() {
    driver.get(System.getProperty("baseUrl", "https://staging.example.in"));
    Assert.assertTrue(driver.getTitle().contains("Home"));
  }
}

<!-- testng.xml: one <test> per browser, same classes -->
<suite name="cross-browser" parallel="tests" thread-count="3">
  <parameter name="gridUrl" value="http://grid.internal:4444"/>
  <test name="chrome">  <parameter name="browser" value="chrome"/>  ... </test>
  <test name="firefox"> <parameter name="browser" value="firefox"/> ... </test>
  <test name="edge">    <parameter name="browser" value="edge"/>    ... </test>
</suite>
💡 Pro Tip: Always add @Optional to parameters used in @BeforeMethod. Without it, running a single test from the IDE fails with a configuration error and juniors waste an afternoon on it.
Q9

How do you assert that a call throws, using expectedExceptions versus Assert.assertThrows?

BasicAssertions

Answer

TestNG offers two mechanisms. The declarative one is the expectedExceptions attribute on @Test, which takes one or more exception classes and passes the test if the method throws any of them, optionally narrowed further with expectedExceptionsMessageRegExp. The programmatic one is Assert.assertThrows, which takes the expected class and a ThrowingRunnable lambda and returns the caught throwable so you can make further assertions on it.

Prefer assertThrows in almost every case. The reason is scope: expectedExceptions applies to the entire method body, so if your setup code inside the test accidentally throws the same exception type for a completely unrelated reason, the test passes and you have a false green. That failure mode is silent and can survive for months. assertThrows narrows the expectation to exactly one statement and gives you the exception object, which lets you assert on an error code, an HTTP status, or a cause chain.

The regex attribute has its own trap: expectedExceptionsMessageRegExp must match the whole message, not a substring, so people write the fragment they expect, the match fails, and TestNG reports the test as failed with a confusing message about the expected exception being thrown but with the wrong message. Wrap the fragment in dot-star on both sides. One more point interviewers like: expectedExceptions does not verify that the exception came from the line you care about, and it cannot express negative cases such as asserting a call does not throw, for which a plain try block or an AssertJ assertion reads better.

import static org.testng.Assert.assertThrows;
import static org.testng.Assert.expectThrows;

public class WalletTests {

  // Declarative: applies to the WHOLE method body
  @Test(expectedExceptions = InsufficientFundsException.class,
        expectedExceptionsMessageRegExp = ".*balance 0.*")
  public void debitFailsOnEmptyWallet() {
    wallet.debit(500);
  }

  // Preferred: scoped to one call, and you get the exception back
  @Test
  public void debitFailsWithCorrectCode() {
    Wallet w = new Wallet(0);

    InsufficientFundsException ex =
        expectThrows(InsufficientFundsException.class, () -> w.debit(500));

    Assert.assertEquals(ex.getCode(), "WALLET_402");
    Assert.assertEquals(ex.getAvailable(), 0);

    // assertThrows is the same check without the returned value
    assertThrows(IllegalArgumentException.class, () -> w.debit(-1));
  }
}

Key Points

  • expectedExceptions covers the whole method and can produce false greens
  • expectedExceptionsMessageRegExp must match the entire message
  • expectThrows returns the exception so you can assert on code or cause
  • assertThrows scopes the expectation to a single statement
Q10

What are the different ways a TestNG test can end up SKIPPED rather than passed or failed?

BasicTest Status

Answer

Skips are their own status in TestNG (ITestResult.SKIP, value 3) and they arise in four distinct ways. First, throwing org.testng.SkipException from inside a test or a configuration method marks that invocation skipped at runtime, which is the correct way to say the environment does not support this scenario, for example a UPI test on a build where the payment gateway sandbox is down. Second, a failed configuration method: if @BeforeMethod or @BeforeClass throws, every test that depended on it is reported skipped, not failed, because TestNG cannot say whether the test itself would have passed.

Third, dependencies: a method with dependsOnMethods or dependsOnGroups is skipped when the upstream method fails or is itself skipped, and the skip cascades down the whole chain. Fourth, a retry analyzer: when IRetryAnalyzer requests a retry, the failed attempt is recorded as skipped in most report renderers, which is why suites with retries often show a puzzling pile of skips. Separately, enabled set to false on @Test removes the method from the run entirely, so it appears in neither the passed nor the skipped count, and @Ignore, available at method, class and package level, does the same thing more readably.

This distinction matters in interviews because skipped tests are frequently ignored on dashboards. A pipeline that reports ninety-eight percent pass while quietly skipping forty tests is not a healthy pipeline, and a good answer names that explicitly: treat unexpected skips as failures in your CI gate.

public class PaymentTests {

  @BeforeMethod
  public void requireSandbox() {
    if (!Sandbox.isUp()) {
      throw new SkipException("payment sandbox unavailable, skipping");
    }
  }

  @Test
  public void upiCollectFlow() { }

  @Test(enabled = false)             // not run, not counted anywhere
  public void netbankingFlowLegacy() { }

  @Ignore                            // same effect, also valid on a class
  @Test
  public void walletTopUpDeprecated() { }

  @AfterMethod
  public void auditStatus(ITestResult r) {
    if (r.getStatus() == ITestResult.SKIP) {
      Reporter.log("SKIPPED: " + r.getName() + " cause=" + r.getThrowable(), true);
    }
  }
}
💡 Pro Tip: Fail the CI job when the skip count is above an agreed threshold. Skips are how coverage quietly evaporates in long-lived automation suites.
Q11

How do dependsOnMethods and dependsOnGroups behave when the upstream test fails?

BasicDependencies

Answer

Dependencies express ordering plus a precondition. dependsOnMethods names one or more methods that must run and pass before this one runs; dependsOnGroups names groups instead, which scales better because you do not have to list every method. If an upstream method fails, TestNG does not run the dependent method at all and reports it SKIPPED, and that skip cascades transitively down the chain. That behaviour is exactly what you want for a linear flow such as create order, then pay, then verify invoice, because running the pay step against an order that was never created produces a second, meaningless failure that clutters triage.

Set alwaysRun to true on the dependent method to force it to run even when the upstream failed, though for a @Test method that is rarely correct. Two important caveats. First, dependsOnMethods only resolves within the same class or an inherited hierarchy by default; depending on a method in an unrelated class requires the fully qualified name and the class being in the same test tag, and even then it is fragile, so use groups for cross-class ordering.

Second, ignoreMissingDependencies set to true stops TestNG from erroring out when a named dependency is not part of the current run, which matters when group filtering excludes the upstream method. The bigger architectural point, and the one senior interviewers are fishing for, is that heavy dependency chains make a suite non-parallelisable and turn one broken login into two hundred skips. Independent tests with API-based setup are the modern preference; dependencies should be reserved for genuine workflow tests.

public class OrderFlowTests {

  @Test(groups = {"order-create"})
  public void createOrder() {
    orderId = api.createOrder("SKU-1001");
    Assert.assertNotNull(orderId);
  }

  // Skipped (not failed) if createOrder fails
  @Test(dependsOnMethods = {"createOrder"}, groups = {"order-pay"})
  public void payOrder() {
    Assert.assertEquals(api.pay(orderId).getStatus(), "CAPTURED");
  }

  // Group-level dependency scales better than naming every method
  @Test(dependsOnGroups = {"order-pay"})
  public void invoiceIsGenerated() {
    Assert.assertTrue(api.invoiceExists(orderId));
  }

  // Runs even if the chain above collapsed, so the order is not left open
  @AfterClass(alwaysRun = true)
  public void cleanUp() {
    if (orderId != null) api.cancel(orderId);
  }
}

Key Points

  • Upstream failure means downstream SKIP, and the skip cascades
  • dependsOnGroups scales better than naming individual methods
  • alwaysRun=true overrides the skip, mainly useful on cleanup config
  • Long dependency chains block parallelism and inflate skip counts
Q12

How do you run a TestNG suite from Maven Surefire and from Gradle, including group filtering?

BasicBuild Tooling

Answer

With Maven, add the testng dependency in test scope and configure maven-surefire-plugin with a suiteXmlFiles entry pointing at your testng.xml. Surefire detects TestNG on the classpath and delegates to it. You can override the suite from the command line with -Dsurefire.suiteXmlFiles, filter groups with -Dgroups and -DexcludedGroups, and pass application config with ordinary system properties, remembering to add them to argLine or to rely on Surefire forwarding user properties.

A frequent production bug is configuring parallelism in two places: Surefire has its own parallel and threadCount options that map onto the TestNG parallel model, and setting both there and in testng.xml gives you thread counts that multiply rather than match, which then exhausts your Selenium Grid session limit. Pick one place, and for TestNG suites that place should be testng.xml. Also set the Surefire testFailureIgnore behaviour deliberately: by default a test failure fails the build, and if you set it to true to let reporting stages run, make sure a later stage actually inspects the results, otherwise red suites deploy.

With Gradle, use the test task with useTestNG, which accepts suites for XML files, includeGroups, excludeGroups and maxParallelForks. Gradle forks JVMs, which is a coarser parallelism than TestNG threads and stacks on top of it, so the same double-counting warning applies. In both tools, publish target/surefire-reports and test-output so Jenkins or GitHub Actions can render results and archive screenshots.

<!-- pom.xml -->
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.5.2</version>
  <configuration>
    <suiteXmlFiles>
      <suiteXmlFile>src/test/resources/${suiteFile}</suiteXmlFile>
    </suiteXmlFiles>
    <!-- do NOT also set <parallel> here if testng.xml already sets it -->
    <argLine>-Xmx2g -Dfile.encoding=UTF-8</argLine>
  </configuration>
</plugin>

<!-- CI invocations -->
<!-- mvn -B test -DsuiteFile=smoke.xml -Denv=staging -->
<!-- mvn -B test -Dgroups=smoke -DexcludedGroups=quarantine -->

// build.gradle equivalent
test {
  useTestNG {
    suites file("src/test/resources/regression.xml")
    includeGroups "smoke"
    excludeGroups "quarantine"
  }
  maxParallelForks = 2      // JVM forks, stacks on top of TestNG threads
  systemProperty "env", System.getProperty("env", "staging")
}
💡 Pro Tip: Never configure parallelism in both Surefire and testng.xml. The counts multiply, and the first symptom is a Grid returning session-not-created errors under load.
Q13

What reports does TestNG generate by default, and what is testng-failed.xml for?

BasicReporting

Answer

After a run TestNG writes into the output directory, test-output by default, or target/surefire-reports when driven by Surefire. The important artefacts are index.html, the navigable HTML report; emailable-report.html, a single self-contained file that people actually paste into release emails; testng-results.xml, the machine-readable results file that Jenkins, Allure and most dashboards parse; and testng-failed.xml, an auto-generated suite file containing only the methods that failed plus their configuration dependencies. That last file is the cheapest rerun mechanism available: point a second Surefire execution at test-output/testng-failed.xml and you rerun only the broken tests instead of a ninety-minute regression.

Be honest about its limits in an interview, because a good panel will push: rerunning failures and reporting the combined result as green hides real product bugs behind infrastructure noise, so the second run should be reported separately, not merged into the primary pass rate. The built-in HTML reports are functional but dated, which is why almost every Indian framework layers Allure (through the allure-testng adapter, which reads testng-results.xml and step annotations) or ExtentReports on top. You can also write your own by implementing IReporter, which receives the full list of ISuite objects after everything finishes, or ITestListener if you want to stream results as they happen. Verbosity is controlled by the verbose attribute on suite or by -Dsurefire.reportFormat, and turning verbose up to a high level in CI is a fast way to debug a suite that silently runs zero tests.

test-output/
  index.html                 <- navigable HTML report
  emailable-report.html      <- single-file summary for release mails
  testng-results.xml         <- parsed by Jenkins, Allure, custom dashboards
  testng-failed.xml          <- suite containing ONLY the failed methods
  <suite-name>/              <- per-suite drilldown

<!-- Rerun only failures as a separate, separately reported stage -->
<execution>
  <id>rerun-failures</id>
  <phase>verify</phase>
  <goals><goal>test</goal></goals>
  <configuration>
    <suiteXmlFiles>
      <suiteXmlFile>test-output/testng-failed.xml</suiteXmlFile>
    </suiteXmlFiles>
  </configuration>
</execution>

<!-- Allure adapter reads testng-results.xml, no test code changes needed -->
<!-- mvn test && mvn allure:report -->

Key Points

  • index.html, emailable-report.html, testng-results.xml, testng-failed.xml
  • testng-failed.xml reruns only broken methods plus their config
  • Report reruns separately; merging them into the pass rate hides real bugs
  • Allure and ExtentReports consume testng-results.xml or listener callbacks
Q14

How does TestNG decide which methods are tests, and what does a class-level @Test change?

BasicTest Discovery

Answer

TestNG discovers candidates from what testng.xml lists (classes, packages, or method includes) and then inspects annotations, so there is no naming convention such as the old testXxx prefix. A method becomes a test when it carries @Test at method level, or when the class carries @Test at class level, in which case every public method of that class that is not a configuration method becomes a test. That class-level behaviour is a genuine trap.

Put @Test on the class and any public helper you wrote, for example a public String buildPayload() used by three tests, silently becomes a test method, runs on its own, and either passes meaninglessly or fails with a null pointer because setup assumptions do not hold. The fix is to make helpers private or protected, or better, avoid class-level @Test in favour of explicit method annotations. Inheritance rules matter too.

Test methods and configuration methods are inherited from superclasses, so a BaseTest with @BeforeMethod applies to every subclass without redeclaration, and @Test methods on an abstract base run once per concrete subclass, which is a deliberate pattern for contract tests but a surprise if you did not intend it. TestNG instantiates the class once per run by default using a public no-argument constructor, unless a @Factory or constructor injection supplies instances, and it reuses that single instance across all test methods in the class, which is why instance fields leak state between tests. Interviewers ask this to see whether you understand that TestNG classes are not fresh per method the way JUnit 4 classes were.

// TRAP: class-level @Test turns every public method into a test
@Test
public class CartTests {

  public void addItem() { }                 // test, as intended
  public void removeItem() { }              // test, as intended

  public String buildPayload() {            // ALSO a test, almost certainly a bug
    return Json.of("sku", "SKU-1");
  }
}

// Safer shape
public class CartTests {

  @Test public void addItem() { }
  @Test public void removeItem() { }

  private String buildPayload() { return Json.of("sku", "SKU-1"); }
}

// One instance is reused for ALL methods in the class:
public class StatefulTests {
  private int counter = 0;                  // leaks between test methods

  @Test public void first()  { counter++; Assert.assertEquals(counter, 1); }
  @Test public void second() { counter++; Assert.assertEquals(counter, 1); } // fails
}

Key Points

  • No naming convention; discovery is annotation plus XML driven
  • Class-level @Test promotes every public method into a test
  • Config and test methods are inherited from superclasses
  • One class instance is reused across all its test methods, so fields leak state
Q15

What do parallel="methods", "classes", "tests" and "instances" actually do, and how is thread-count applied?

IntermediateParallel Execution

Answer

parallel is an attribute on the suite element or on an individual test element, and it accepts none (the default), methods, classes, tests or instances. methods gives every @Test invocation its own slot in a shared thread pool, which is the highest concurrency and the most dangerous, because two methods of the same class now execute simultaneously against a single class instance. classes keeps all methods of one class on one thread and distributes classes across the pool, which is the sane default for Selenium since one class then owns one browser for its whole lifetime. tests gives each test tag its own thread, so the classic cross-browser suite with one tag per browser runs three browsers concurrently while each browser executes its methods sequentially. instances parallelises across the objects produced by a @Factory, which is how you fan the same class out over many tenants or many payment modes. thread-count sets the pool size and defaults to 5, and a test element may carry its own thread-count that overrides the suite value for that tag only. Two behaviours surprise people. First, TestNG builds a fixed pool of ordinary platform threads, so thread-count is a hard ceiling on concurrent browser sessions and nothing about Java 21 virtual threads changes that; if your Grid allows sixteen sessions and you set thread-count to thirty-two, the excess fails with a session not created error instead of queueing politely.

Second, under parallel equals methods, @BeforeClass still runs once, and the thread that runs it is not guaranteed to be a thread that later runs your test methods, so anything you push into a ThreadLocal from @BeforeClass is invisible where you need it. That one detail explains most of the null driver errors that appear when a team flips a suite from classes to methods to make the nightly run finish faster.

<!-- One browser per <test> tag, three tags in parallel -->
<suite name="cross-browser" parallel="tests" thread-count="3"> ... </suite>

<!-- Safest Selenium default: one class = one thread = one driver -->
<suite name="regression" parallel="classes" thread-count="6"> ... </suite>

<!-- Highest concurrency, requires fully thread-safe test classes -->
<suite name="api" parallel="methods" thread-count="16"> ... </suite>

<!-- Fan out @Factory instances, one instance per thread -->
<suite name="tenants" parallel="instances" thread-count="8"> ... </suite>

<!-- A <test> tag can override the suite setting for itself only -->
<suite name="mixed" parallel="classes" thread-count="4">
  <test name="serial-payments" parallel="none">
    <classes><class name="tests.PaymentTests"/></classes>
  </test>
  <test name="fast-api" parallel="methods" thread-count="12">
    <classes><class name="tests.CatalogApiTests"/></classes>
  </test>
</suite>

Key Points

  • thread-count defaults to 5 and is a hard cap on concurrent sessions
  • classes keeps one class on one thread, the safe Selenium default
  • methods shares one class instance across threads, so fields must go
  • @BeforeClass under parallel=methods may run on a thread no test uses
💡 Pro Tip: Size thread-count against your Grid session limit, not your CPU count. Selenium tests are I/O bound, and the constraint that bites first is the remote session cap.
Q16

Why does a static WebDriver field destroy a parallel run, and how does ThreadLocal fix it?

IntermediateConcurrency

Answer

A static WebDriver means one browser for the whole JVM. Serially that works and nobody notices. Switch the suite to parallel equals methods or classes and every thread now drives the same window: thread A navigates to the cart while thread B is asserting on the login page, so you get StaleElementReferenceException, NoSuchElementException on elements that visibly exist, and screenshots that show a completely unrelated screen.

The tell is that every test passes when run alone and the failures move around between runs. Instance fields are only slightly better. TestNG creates one instance per class and reuses it for all its methods, so under parallel equals methods an instance field is shared exactly like a static one; it is safe only under parallel equals classes or instances.

The standard fix is a ThreadLocal in a driver factory: set the driver in @BeforeMethod, read it through a getter everywhere else, and call remove() in @AfterMethod. remove() is the part people skip, and it matters twice. TestNG reuses pool threads, so a stale entry left behind is visible to whichever test lands on that thread next, and the pool thread holds a strong reference to the WebDriver object for the entire run, which is a real leak on a suite of several thousand tests. The same discipline applies to everything else you kept as a field: ExtentTest nodes, SoftAssert instances, API tokens, the current order id.

Page objects should receive the driver through their constructor rather than reading a static, otherwise you have simply moved the shared state one class further away. Interviewers often show a framework with a public static WebDriver driver and ask what happens when thread-count goes from 1 to 5, and they are listening for whether you name instance fields as well as statics.

public final class DriverFactory {

  private static final ThreadLocal<WebDriver> TL = new ThreadLocal<>();

  public static void create(String browser) {
    TL.set(browser.equals("firefox") ? new FirefoxDriver() : new ChromeDriver());
  }

  public static WebDriver get() {
    WebDriver d = TL.get();
    if (d == null) throw new IllegalStateException("no driver on " + Thread.currentThread().getName());
    return d;
  }

  public static void destroy() {
    WebDriver d = TL.get();
    if (d != null) d.quit();
    TL.remove();          // pool threads are reused: without this the next test inherits it
  }
}

public class BaseTest {

  @BeforeMethod(alwaysRun = true)
  @Parameters("browser")
  public void openBrowser(@Optional("chrome") String browser) { DriverFactory.create(browser); }

  @AfterMethod(alwaysRun = true)
  public void closeBrowser() { DriverFactory.destroy(); }
}

Key Points

  • One class instance serves all its methods, so instance fields leak under parallel=methods
  • ThreadLocal set in @BeforeMethod, read through a getter, removed in @AfterMethod
  • Skipping remove() leaks the driver for the whole run and poisons the next test
  • Pass the driver into page objects instead of exposing a static
💡 Pro Tip: Say the symptom out loud: passes alone, fails in a suite, failures move around. That sentence tells an interviewer you have actually debugged shared state rather than read about it.
Q17

Which ITestListener callbacks fire, and how do you implement screenshot-on-failure without breaking on timeouts?

IntermediateListeners

Answer

ITestListener exposes onStart(ITestContext) and onFinish(ITestContext) around the whole test tag, plus per-method callbacks: onTestStart, onTestSuccess, onTestFailure, onTestSkipped, onTestFailedButWithinSuccessPercentage and onTestFailedWithTimeout. They are default methods on the interface, so you implement only what you need instead of writing seven empty bodies. The timeout callback is the one people miss: when a method breaches @Test(timeOut), TestNG routes it to onTestFailedWithTimeout, whose default implementation delegates to onTestFailure, but if you override it without calling through you lose screenshots on exactly the failures you most want evidence for.

For a screenshot listener the driver has to be reachable from the callback. Reading a static field defeats parallel execution, so the correct route is either ITestResult.getInstance() cast to your base class, or the same ThreadLocal factory the tests use, since listener callbacks run on the thread that executed the test. Ordering matters as well: onTestFailure fires before @AfterMethod, which is why the screenshot still succeeds when the driver is quit in @AfterMethod, and why moving the quit into the test body gives you a NoSuchSessionException inside the listener.

Two more production notes. An exception thrown from a listener is not reported as a test failure and can quietly abort reporting, so wrap the whole callback in a try block. And ITestContext.setAttribute is shared across all threads in the tag, so anything you accumulate there (failure counts, artefact paths) needs a concurrent collection. Register the listener in testng.xml rather than with @Listeners when you want it applied to every class without editing them.

public class ScreenshotListener implements ITestListener {

  @Override
  public void onTestFailure(ITestResult result) { capture(result); }

  @Override
  public void onTestFailedWithTimeout(ITestResult result) { capture(result); }

  private void capture(ITestResult result) {
    try {
      WebDriver driver = DriverFactory.get();      // same ThreadLocal, same thread
      byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
      Path out = Paths.get("test-output", "shots", result.getName() + "-"
          + result.getEndMillis() + ".png");
      Files.createDirectories(out.getParent());
      Files.write(out, png);
      Reporter.log("<a href=\"" + out + "\">screenshot</a>", true);
    } catch (Exception ignored) {
      // never let a listener exception abort the reporting phase
    }
  }

  @Override
  public void onFinish(ITestContext ctx) {
    System.out.printf("%s: %d passed, %d failed, %d skipped%n", ctx.getName(),
        ctx.getPassedTests().size(), ctx.getFailedTests().size(),
        ctx.getSkippedTests().size());
  }
}

Key Points

  • Callbacks are default methods, so override only what you use
  • onTestFailedWithTimeout is separate; ignoring it loses timeout screenshots
  • onTestFailure runs before @AfterMethod, so the session is still alive
  • ITestContext attributes are shared across threads and need concurrent structures
Q18

How do you apply IRetryAnalyzer to every test in a suite without annotating each method?

IntermediateRetries

Answer

IRetryAnalyzer has one method, retry(ITestResult), and returning true makes TestNG invoke the method again. You can wire it per method with @Test(retryAnalyzer = MyRetry.class), but nobody wants that on four hundred methods, and new tests will forget it. The suite-wide route is IAnnotationTransformer: implement transform(ITestAnnotation annotation, Class testClass, Constructor ctor, Method method), call annotation.setRetryAnalyzer(MyRetry.class), and register the transformer in the listeners element of testng.xml so it rewrites every @Test as TestNG reads it.

The counting is where implementations go wrong. A plain int field is per analyzer instance, and with a data provider you can find one row consuming the entire budget while later rows get none, so either extend org.testng.util.RetryAnalyzerCount, which manages the count for you through retryMethod(ITestResult), or key a ConcurrentHashMap on the method name plus the parameter values from ITestResult.getParameters(). Reporting is the second trap.

A failed attempt that is going to be retried is recorded in most renderers as skipped, which is why suites with retries show a confusing pile of skips, and modern TestNG exposes ITestResult.wasRetried() so listeners can label those results properly instead of counting them as genuine skips. The part senior interviewers care about is policy, not code. Retries hide real product bugs: a race condition in checkout that fails one run in four becomes invisible once you retry twice and report green.

Cap retries at one, never retry assertion failures blindly (restrict to known infrastructure exceptions such as WebDriverException or a socket timeout), and publish a flaky count that a human reads every week. A framework that retries three times and reports a single merged pass rate is a framework that has stopped detecting regressions.

public class InfraRetry extends RetryAnalyzerCount {

  public InfraRetry() { setCount(1); }          // exactly one extra attempt

  @Override
  public boolean retryMethod(ITestResult result) {
    Throwable t = result.getThrowable();
    // never retry a genuine assertion failure
    if (t instanceof AssertionError) return false;
    return t instanceof WebDriverException
        || t instanceof java.net.SocketTimeoutException;
  }
}

public class RetryTransformer implements IAnnotationTransformer {
  @Override
  public void transform(ITestAnnotation annotation, Class<?> testClass,
                        Constructor<?> ctor, Method method) {
    annotation.setRetryAnalyzer(InfraRetry.class);   // applied to every @Test
  }
}

public class FlakyReporter implements ITestListener {
  @Override public void onTestSuccess(ITestResult r) {
    if (r.wasRetried()) Reporter.log("FLAKY PASS: " + r.getName(), true);
  }
}

<!-- testng.xml -->
<listeners>
  <listener class-name="listeners.RetryTransformer"/>
  <listener class-name="listeners.FlakyReporter"/>
</listeners>

Key Points

  • IAnnotationTransformer.setRetryAnalyzer applies retries suite-wide
  • RetryAnalyzerCount handles the counter; a raw int field misbehaves with data providers
  • Retried failures surface as skips; ITestResult.wasRetried() distinguishes them
  • Retry only infrastructure exceptions, cap at one, and report flakes separately
💡 Pro Tip: If you say retries fix flakiness, expect a follow-up on how you avoid hiding real bugs. Have the answer ready: retry only on infra exceptions and track the flaky rate as its own metric.
Q19

What problem does @Factory solve that @DataProvider cannot?

IntermediateFactories

Answer

A data provider parameterises one test method. A factory parameterises the whole test class. @Factory annotates a method returning Object[] of test class instances, or annotates a constructor, and TestNG runs every @Test method in each returned instance, along with that instance's @BeforeClass and @AfterClass. So if you have a twenty-method checkout class and five tenants, a data provider forces you to add a parameter and a provider to all twenty methods, while a factory gives you five fully independent copies of the class with one method.

The factory method itself can take a @DataProvider, which is the usual combination: the provider supplies rows, the factory turns each row into a configured instance. Set parallel to instances in the suite and those copies run concurrently, one per thread, which also makes instance fields safe again because each thread owns its own object. Two practical problems come with factories.

First, reporting: every instance produces identically named results, so index.html shows checkoutWithCoupon five times with no way to tell which tenant failed. The fix is to implement org.testng.ITest and return a per-instance name from getTestName(), or to give the class a meaningful toString(), which modern TestNG uses when naming instances. Second, ordering: dependsOnMethods across instances can interleave, so set group-by-instances to true on the suite or test element to keep each instance's methods together. Factories are also the right answer when someone asks how to run the same suite against multiple environments or multiple API versions inside a single JVM run, and they are how tenant-per-instance suites are built at Indian SaaS and fintech teams where the same regression pack has to prove out several white-labelled deployments.

public class CheckoutTests implements ITest {

  private final String tenant;
  private String testName;

  @Factory(dataProvider = "tenants")
  public CheckoutTests(String tenant) { this.tenant = tenant; }

  @DataProvider(name = "tenants")
  public static Object[][] tenants() {
    return new Object[][] { {"acme-in"}, {"zeta-in"}, {"nova-in"} };
  }

  @BeforeMethod
  public void nameIt(Method m) { testName = m.getName() + "[" + tenant + "]"; }

  @Override
  public String getTestName() { return testName; }   // report shows the tenant

  @Test
  public void checkoutWithCoupon() { Assert.assertTrue(Api.of(tenant).applyCoupon("IND10")); }

  @Test
  public void checkoutWithUpi() { Assert.assertEquals(Api.of(tenant).payUpi().status(), "CAPTURED"); }
}

<!-- run the three instances on three threads -->
<suite name="tenants" parallel="instances" thread-count="3" group-by-instances="true">

Key Points

  • @DataProvider parameterises a method; @Factory parameterises the class
  • A factory can itself be fed by a data provider
  • parallel=instances gives each instance its own thread, so fields are safe again
  • Implement ITest.getTestName() or reports cannot tell the instances apart
Q20

How do timeOut, invocationTimeOut and the suite time-out attribute behave, and why is timeOut a poor primary defence?

IntermediateTimeouts

Answer

@Test(timeOut = 5000) sets a per-invocation budget in milliseconds. TestNG runs that method on a separate worker, and if the budget is exceeded it records a ThreadTimeoutException with a message of the form Method X did not finish within the time-out 5000, and routes the result to onTestFailedWithTimeout. invocationTimeOut caps the total time across all invocations when you also set invocationCount, so invocationCount 10 with invocationTimeOut 30000 fails once the ten runs collectively exceed thirty seconds. The time-out attribute on the suite or test element in testng.xml applies the same per-method budget to everything in scope, which is a cheap way to stop one hung test from stalling a nightly run.

Configuration methods honour timeOut too, so a @BeforeMethod that hangs is bounded. The important caveat, and the one interviewers want, is that a timed-out method is not reliably stopped. TestNG interrupts the worker, but a thread blocked inside a socket read (which is exactly what a WebDriver command or a RestAssured call is) does not respond to interrupt, so the work continues in the background, holds its browser session, and can still mutate shared data long after the test was marked failed.

Under a parallel suite that means leaked Grid sessions and cascading session not created failures. So the real defence lives in the clients: pageLoadTimeout, scriptTimeout and implicit or explicit wait configuration on WebDriver, connect and read timeouts on the HTTP client, statement timeouts on JDBC. Use TestNG timeOut as a backstop set well above the client timeouts, not as the mechanism that is expected to fire.

public class SearchTests {

  // per invocation budget; failure is ThreadTimeoutException
  @Test(timeOut = 5000)
  public void suggestionsAppearQuickly() { }

  // 10 runs, 30s total across all of them
  @Test(invocationCount = 10, invocationTimeOut = 30000)
  public void repeatedSearchStaysFast() { }

  @BeforeMethod(timeOut = 20000)   // config methods honour timeOut too
  public void login() { }
}

// The real defence: client-side timeouts that actually abort the socket
driver.manage().timeouts()
      .pageLoadTimeout(Duration.ofSeconds(30))
      .scriptTimeout(Duration.ofSeconds(20))
      .implicitlyWait(Duration.ZERO);      // prefer explicit waits

<!-- suite-wide backstop, per method -->
<suite name="nightly" time-out="120000" parallel="classes" thread-count="6">

Key Points

  • timeOut is per invocation; invocationTimeOut is the total across invocationCount
  • time-out on suite or test applies the same budget to every method in scope
  • A thread blocked in a socket read ignores the interrupt and leaks its session
  • Set real timeouts on WebDriver and the HTTP client; keep timeOut as a backstop
💡 Pro Tip: Answer this one by naming the leaked Grid session. Candidates who only say the test fails after five seconds have not run a suite that timed out at scale.
Q21

What do invocationCount, threadPoolSize and successPercentage do together?

IntermediateRepetition

Answer

invocationCount runs a single @Test method n times in one suite run, and each invocation is a separate ITestResult in the report. threadPoolSize, which is only meaningful alongside invocationCount greater than one, spreads those invocations across a dedicated pool of that size, so invocationCount 20 with threadPoolSize 5 gives twenty runs, five at a time. successPercentage declares the method passed when at least that percentage of invocations pass, and the sub-threshold failures land in the onTestFailedButWithinSuccessPercentage listener callback rather than in onTestFailure. There are three honest uses. Detecting flakiness: run a suspicious test twenty times locally with threadPoolSize 1 and count the failures, which is far better evidence than an argument in a stand-up about whether a test is flaky.

Light concurrency sanity on an API: invocationCount 50 with threadPoolSize 10 will surface an obvious race or connection pool exhaustion, though it is not a load test and you should not pretend it is. Warming a cache before a timing assertion. The gotchas matter. @BeforeMethod runs before every invocation, so a UI login in setup turns invocationCount 50 into fifty logins and a very long run.

The report becomes noisy, since one method now occupies fifty rows. threadPoolSize is separate from the suite thread-count and multiplies with it, so a parallel suite plus a threadPoolSize method can open far more sessions than you budgeted. And successPercentage on a functional test is a way of formally agreeing to ship with known failures, so keep it for genuinely statistical checks and never put it on a payment flow.

public class StabilityTests {

  // 20 sequential runs: the cheapest way to prove a test is flaky
  @Test(invocationCount = 20, threadPoolSize = 1)
  public void cartTotalIsStable() { }

  // 50 runs, 10 concurrent: surfaces pool exhaustion and obvious races
  @Test(invocationCount = 50, threadPoolSize = 10)
  public void quoteApiHandlesConcurrency() {
    Assert.assertEquals(RestAssured.get("/api/quote").statusCode(), 200);
  }

  // Passes if at least 90% of invocations pass; failures below that
  // go to onTestFailedButWithinSuccessPercentage, not onTestFailure
  @Test(invocationCount = 10, successPercentage = 90)
  public void thirdPartyPincodeLookup() { }

  @BeforeMethod
  public void setUp() { }   // careful: this runs once PER invocation
}

Key Points

  • threadPoolSize only applies when invocationCount is greater than one
  • @BeforeMethod fires once per invocation, not once per method
  • threadPoolSize stacks on top of suite thread-count
  • successPercentage routes near-misses to a separate listener callback
Q22

What really controls execution order: priority, preserve-order, dependsOnMethods or group-by-instances?

IntermediateExecution Order

Answer

priority is an int with a default of 0, lower values scheduled first, and it is the weakest of the four. It only orders methods that carry no other constraint, it applies within a test tag, and under parallel execution it affects the order tasks are submitted to the pool, not the order they complete, so two priority 1 tests can finish after a priority 5 test. Methods sharing a priority have no defined relative order, and the apparent alphabetical ordering people rely on is a reflection artefact, not a contract. preserve-order, an attribute on the test element that defaults to true, keeps classes and the methods listed inside them in the sequence written in testng.xml; set it to false and TestNG may reorder freely. dependsOnMethods and dependsOnGroups are the only genuine ordering guarantees, because they express a real edge in the execution graph, and they carry a precondition as well: the dependent method is skipped, not run, if the upstream fails. group-by-instances solves a different axis: with a @Factory producing several instances, TestNG by default interleaves the same method across all instances, and setting group-by-instances to true keeps each instance's methods together, which matters when your instances hold per-tenant state.

The answer interviewers actually want is architectural. Anything that depends on ordering cannot be parallelised, cannot be sharded across CI runners, and cannot be rerun in isolation from testng-failed.xml, so ordering should be a deliberate decision for genuine workflow tests only. Everywhere else, seed state through API calls in @BeforeMethod so each test stands alone, and treat a suite that needs priority numbers on fifty methods as a design smell rather than a feature.

public class OrderingTests {

  @Test(priority = 1)  public void a() { }   // scheduled first, may not FINISH first
  @Test(priority = 1)  public void b() { }   // same priority: no defined order vs a()
  @Test(priority = 10) public void z() { }

  // The only hard guarantee, and it carries a precondition
  @Test(dependsOnMethods = "a")
  public void afterA() { }
}

<!-- preserve-order keeps the XML sequence; default is true -->
<test name="workflow" preserve-order="true">
  <classes>
    <class name="tests.CreateOrderTests"/>
    <class name="tests.PayOrderTests"/>
  </classes>
</test>

<!-- keep each @Factory instance together instead of interleaving methods -->
<suite name="tenants" group-by-instances="true" parallel="instances" thread-count="4">

Key Points

  • priority orders submission, not completion, once the suite is parallel
  • Equal priorities have no guaranteed relative order
  • dependsOnMethods and dependsOnGroups are the only real ordering contract
  • group-by-instances keeps factory instances from interleaving
💡 Pro Tip: When asked how to make test B run after test A, say dependsOnMethods and then explain why you would rather make B independent. Reaching for priority first reads as junior.
Q23

Which parameters can TestNG inject into test and configuration methods without a data provider?

IntermediateDependency Injection

Answer

TestNG resolves a small set of parameter types by type, with no annotation required, which is called native injection. Test methods and configuration methods can declare ITestContext to reach the running test tag, and XmlTest for the parsed XML including its parameters. Configuration methods get more: @BeforeMethod and @AfterMethod can declare java.lang.reflect.Method to see which test method is about to run or has just run, ITestResult to inspect the outcome (which is how the older screenshot-on-failure pattern works before listeners take over), and Object[] to receive the exact data provider row that the upcoming test will be given, which is what you need to name an Extent or Allure node per row. @BeforeSuite, @AfterSuite, @BeforeTest and @AfterTest can take ITestContext.

A @DataProvider method can declare Method and ITestContext for the same reasons. These injected parameters coexist with @Parameters and with data provider arguments, since TestNG matches by type rather than position, though mixing all three in one signature is a readability problem more than a technical one. The most useful of the set in practice is ITestContext, because setAttribute and getAttribute give you a suite-tag-scoped bag for things like a shared auth token or the list of created entity ids that @AfterTest has to clean up.

The trap is that this bag is shared by every thread in the tag, so a plain ArrayList stored there will corrupt under parallel execution; use a synchronized or concurrent collection. ITestContext also exposes getPassedTests, getFailedTests, getSkippedTests and getCurrentXmlTest, which is how custom reporters and CI gates read the run without parsing testng-results.xml.

public class InjectionDemo {

  @BeforeSuite
  public void seed(ITestContext ctx) {
    ctx.setAttribute("authToken", Api.login("qa@goodspace.ai"));
    ctx.setAttribute("createdIds", Collections.synchronizedList(new ArrayList<String>()));
  }

  @BeforeMethod
  public void startNode(Method m, Object[] row, ITestContext ctx) {
    String label = m.getName() + (row.length > 0 ? Arrays.toString(row) : "");
    ExtentManager.startNode(label, (String) ctx.getAttribute("authToken"));
  }

  @Test(dataProvider = "pincodes")
  public void serviceabilityByPincode(String pincode) { }

  @AfterMethod
  public void record(ITestResult result, ITestContext ctx) {
    if (result.getStatus() == ITestResult.FAILURE) {
      ((List<String>) ctx.getAttribute("createdIds")).add(result.getName());
    }
  }

  @AfterSuite
  public void cleanUp(ITestContext ctx) {
    ((List<String>) ctx.getAttribute("createdIds")).forEach(Api::delete);
  }
}

Key Points

  • ITestContext and XmlTest inject almost anywhere; Method and ITestResult into config methods
  • Object[] in @BeforeMethod gives you the upcoming data provider row
  • ITestContext attributes are shared across threads, so use concurrent collections
  • getPassedTests and getFailedTests let a listener gate CI without parsing XML
Q24

What does @DataProvider(parallel = true) change, and how does data-provider-thread-count interact with thread-count?

IntermediateData Providers

Answer

Marking a provider parallel makes the invocations it feeds run concurrently rather than one row after another. The provider method itself is still called once; what changes is that TestNG dispatches the resulting invocations onto a pool whose size comes from data-provider-thread-count on the suite element, defaulting to 10, and which is entirely separate from thread-count. That separation is the whole gotcha.

A suite with parallel equals methods and thread-count 4 plus a parallel data provider can have four method-level threads each fanning rows out onto ten more, so your actual concurrency is nowhere near four and your Grid starts refusing sessions. Set data-provider-thread-count explicitly whenever you turn a provider parallel, or override it from CI with the corresponding system property rather than editing XML per pipeline. The second consideration is state.

All rows still execute against the same class instance, because a parallel provider does not create new instances, so any instance field the test writes is now contested exactly as it would be under parallel equals methods. If you need per-row isolation of objects rather than just of invocations, a @Factory with parallel equals instances is the correct tool, not a parallel provider. Where this genuinely pays off is API and data validation: five hundred RestAssured requests against a pricing endpoint drop from twelve minutes to under two, with no browser to worry about.

For Selenium it means one browser per concurrent row, which is usually not what the person who flipped the flag expected. Report volume is the last thing to watch, since every row is still its own ITestResult and a large parallel provider produces both a heavy report and a large retained object graph.

@DataProvider(name = "pincodes", parallel = true)
public Object[][] pincodes() {
  return new Object[][] { {"110044"}, {"560001"}, {"400001"}, {"700016"} };
}

@Test(dataProvider = "pincodes")
public void serviceabilityIsReturned(String pincode) {
  // safe: no shared instance state, pure API call
  RestAssured.given().queryParam("pin", pincode)
      .get("/api/serviceability").then().statusCode(200);
}

<!-- Pool size for parallel providers is SEPARATE from thread-count -->
<suite name="api" parallel="methods" thread-count="4" data-provider-thread-count="8">
  ...
</suite>

<!-- effective concurrency here is up to 4 x 8, not 4. Size it against the
     Grid session cap or the API rate limit before enabling it. -->

Key Points

  • The provider is still invoked once; only the test invocations go parallel
  • data-provider-thread-count defaults to 10 and is independent of thread-count
  • Rows share the same class instance, so instance fields are contested
  • Use @Factory with parallel=instances when you need per-row object isolation
💡 Pro Tip: Multiply the two thread counts out loud in the interview. Naming the combined concurrency is the detail that separates people who have tuned a suite from people who have copied one.
Q25

What can you do with IMethodInterceptor that groups and priorities cannot?

IntermediateInterceptors

Answer

IMethodInterceptor has a single method, List<IMethodInstance> intercept(List<IMethodInstance> methods, ITestContext context), and TestNG calls it once per test tag after it has collected every runnable method but before execution starts. Whatever list you return is the run. That gives you two powers that annotations cannot: reordering by information that only exists at runtime, and filtering by information that lives outside the code.

Concrete uses from real pipelines. Read last night's testng-results.xml and hoist previously failed tests to the front so a broken build fails in two minutes instead of forty. Sort by historical duration so long tests start first, which is the single cheapest way to balance shards across CI runners with a fixed wall-clock budget.

Query your issue tracker at suite start and drop every test whose linked ticket is still open, instead of maintaining a hand-edited quarantine list. Shard deterministically by taking the index modulo the runner count, so eight GitHub Actions jobs cover the suite exactly once with no XML duplication. Three cautions.

Dependencies still win: dependsOnMethods reasserts itself over anything you return, so do not try to break a dependency chain with an interceptor. Returning an empty list is legal and produces a run reporting zero tests, which is a genuinely common cause of a green pipeline that tested nothing, so guard the filter and fail loudly when the result is empty. And multiple interceptors chain in registration order, each receiving the previous one's output, which makes a stack of them hard to reason about; one interceptor per suite is the maintainable answer.

public class ShardInterceptor implements IMethodInterceptor {

  private static final int TOTAL = Integer.getInteger("shard.total", 1);
  private static final int INDEX = Integer.getInteger("shard.index", 0);

  @Override
  public List<IMethodInstance> intercept(List<IMethodInstance> methods, ITestContext ctx) {
    List<IMethodInstance> sorted = new ArrayList<>(methods);
    // longest first: better balance across runners with a fixed wall clock
    sorted.sort(Comparator.comparingLong(
        m -> -History.medianMillis(m.getMethod().getQualifiedName())));

    List<IMethodInstance> mine = new ArrayList<>();
    for (int i = 0; i < sorted.size(); i++) {
      if (i % TOTAL == INDEX) mine.add(sorted.get(i));
    }
    if (mine.isEmpty()) {
      throw new IllegalStateException("shard " + INDEX + " selected zero tests");
    }
    return mine;
  }
}

<!-- mvn test -Dshard.total=8 -Dshard.index=3 -->
<listeners><listener class-name="listeners.ShardInterceptor"/></listeners>

Key Points

  • Runs once per <test> tag with the full method list, before execution
  • Return value is the run; an empty list silently tests nothing
  • Ideal for failed-first ordering, duration balancing and deterministic sharding
  • dependsOnMethods still overrides whatever order you return
Q26

What are the three ways to register a TestNG listener, and why can @Listeners not register an IAnnotationTransformer?

IntermediateListeners

Answer

Option one is @Listeners on a test class, which despite appearances applies suite-wide rather than to that class alone, so putting it on a base class and also on two subclasses registers the same listener three times and you get three screenshots per failure. Option two is the listeners element in testng.xml with listener class-name entries, which is explicit, reviewable and the right default for a project. Option three is ServiceLoader discovery: put the fully qualified class name in META-INF/services/org.testng.ITestNGListener inside a jar and TestNG picks it up automatically with no configuration at all, which is how a shared internal framework jar can ship reporting and retry behaviour to a dozen product teams without asking any of them to edit XML.

There is also TestNG.addListener when you drive the runner programmatically, and the -listener option on the command line. The IAnnotationTransformer exception is a sequencing fact rather than an arbitrary rule. A transformer exists to rewrite annotations as TestNG reads them, which happens before any test class is loaded and inspected for @Listeners, so a transformer declared that way would be discovered too late to do anything.

Register transformers in testng.xml, through ServiceLoader, or programmatically. Two related details worth having ready: registering the same listener through two mechanisms genuinely runs it twice, since TestNG does not deduplicate by class in every path, and TestNG.setUseDefaultListeners(false) turns off the built-in HTML and XML reporters when you have replaced them with Allure and no longer want test-output written at all.

// 1. Annotation: looks class-scoped, actually applies to the whole suite
@Listeners({ScreenshotListener.class})
public class BaseTest { }

<!-- 2. XML: explicit and reviewable, the project default -->
<listeners>
  <listener class-name="listeners.ScreenshotListener"/>
  <listener class-name="listeners.RetryTransformer"/>   <!-- transformers MUST go here -->
</listeners>

// 3. ServiceLoader: zero config, ships inside a shared framework jar
// src/main/resources/META-INF/services/org.testng.ITestNGListener
//   com.acme.qa.ScreenshotListener
//   com.acme.qa.RetryTransformer

// 4. Programmatic
TestNG ng = new TestNG();
ng.addListener(new ScreenshotListener());
ng.setUseDefaultListeners(false);   // no test-output HTML when Allure owns reporting
ng.run();

Key Points

  • @Listeners is suite-wide, so duplicating it across classes duplicates the work
  • IAnnotationTransformer must be in XML, ServiceLoader or code, never @Listeners
  • META-INF/services/org.testng.ITestNGListener gives zero-config discovery
  • setUseDefaultListeners(false) suppresses the built-in reports
💡 Pro Tip: If failures produce two identical screenshots, look for a listener registered both by annotation and in XML. It is a five-minute fix that teams live with for months.
Q27

What does configfailurepolicy do, and what happens to the rest of the suite when a @BeforeMethod throws?

IntermediateConfiguration Policy

Answer

configfailurepolicy is an attribute on the suite element with two values, skip (the default) and continue. Under skip, the first failure of a configuration method causes TestNG to give up on that configuration for the remaining scope: the dependent tests are marked skipped and TestNG does not keep re-attempting the failed @BeforeMethod for every subsequent test in the class. Under continue, TestNG retries the configuration method before each test anyway, so a transient failure costs you one skipped test instead of two hundred.

Which one you want depends on the failure. If your @BeforeMethod launches a browser and the Grid was momentarily saturated, continue is clearly right, because the next attempt will probably succeed. If your @BeforeSuite failed to read the environment config, continue just produces two hundred identical stack traces and a slower red build.

The practical answer most teams land on is continue at suite level combined with a fail-fast check in @BeforeSuite for the things that genuinely cannot recover. Two reporting traps come with this. Configuration failures appear in their own section of the TestNG report rather than in the failed test count, and several XML converters that turn testng-results.xml into JUnit format for Jenkins drop them, so a suite where every single test skipped because setup died can be published as a green build with zero failures.

Gate on the skip count as well as the failure count. Second, cleanup: an @AfterMethod without alwaysRun set to true is itself skipped when the matching setup failed, so the half-created records the failed setup left behind never get cleaned up. Put alwaysRun on every teardown.

<!-- Default is skip: one transient setup failure can cascade into hundreds -->
<suite name="regression" configfailurepolicy="continue" parallel="classes" thread-count="6">

// Programmatic equivalent when you build the suite in code
XmlSuite suite = new XmlSuite();
suite.setConfigFailurePolicy(XmlSuite.FailurePolicy.CONTINUE);

public class BaseTest {

  @BeforeSuite(alwaysRun = true)
  public void failFastOnConfig() {
    // things that cannot recover should stop the run immediately
    Objects.requireNonNull(System.getProperty("env"), "-Denv is mandatory");
  }

  @BeforeMethod(alwaysRun = true)
  public void openBrowser() { DriverFactory.create("chrome"); }   // transient, retryable

  @AfterMethod(alwaysRun = true)      // without alwaysRun this is skipped too
  public void teardown() { DriverFactory.destroy(); }
}

Key Points

  • skip is the default; continue re-attempts the config method for each test
  • Config failures sit in their own report section, outside the failed count
  • JUnit-format converters can drop them, producing a green build that ran nothing
  • Teardown needs alwaysRun=true or failed setup leaves data behind
Q28

When would you implement IHookable or IConfigurable, and what is the failure mode if you get it wrong?

IntermediateInterceptors

Answer

IHookable wraps the invocation of every test method. You implement run(IHookCallBack callBack, ITestResult testResult) and TestNG hands you the invocation as a callback instead of calling the method directly, so you can do work before and after it inside the same thread and the same stack. IConfigurable does the same for configuration methods through run(IConfigureCallBack callBack, ITestResult testResult).

The classic uses are things a listener cannot do because a listener is notified around the call rather than wrapping it: opening a database transaction and rolling it back so each test leaves no residue, establishing a Spring Security or tenant context that the method body reads from a ThreadLocal, setting the thread context class loader, or attaching a per-test MDC value so application logs can be correlated with the test that produced them. The failure mode is memorable and worth naming in an interview: if you forget to call callBack.runTestMethod(testResult), TestNG reports the test as passed without ever executing the body. An entire suite can turn green in seconds and nobody notices until a release ships broken.

Any early return in your wrapper, including an exception path that skips the call, has the same effect. Guard it in a try-finally and assert somewhere that the invocation actually happened. Two more notes.

Historically IHookable had to be implemented by the test class or its base class; modern TestNG also lets you register it as a listener, which keeps it out of the inheritance hierarchy. And it wraps every test in scope, so anything expensive you put inside it is paid per test, which shows up quickly on a suite of several thousand methods.

public class TransactionalHook implements IHookable {

  @Override
  public void run(IHookCallBack callBack, ITestResult testResult) {
    Transaction tx = Db.begin();
    MDC.put("testName", testResult.getName());
    try {
      callBack.runTestMethod(testResult);   // FORGET THIS AND THE TEST "PASSES"
    } finally {
      tx.rollback();                        // each test leaves the DB untouched
      MDC.remove("testName");
    }
  }
}

public class TenantConfigHook implements IConfigurable {
  @Override
  public void run(IConfigureCallBack callBack, ITestResult testResult) {
    TenantContext.set("acme-in");
    try {
      callBack.runConfigurationMethod(testResult);
    } finally {
      TenantContext.clear();
    }
  }
}

Key Points

  • IHookable wraps test invocation; IConfigurable wraps configuration methods
  • Missing runTestMethod reports a pass without running anything
  • Right tool for transactions, security context and log correlation
  • Cost is paid per test, so keep the wrapper cheap
💡 Pro Tip: Name the silent-pass failure explicitly. It is the detail that shows you have written a hook rather than read the interface signature.
Q29

How do you build and run a TestNG suite programmatically, and why would you do that instead of shipping testng.xml?

AdvancedProgrammatic API

Answer

Everything testng.xml expresses has a Java counterpart: XmlSuite for the suite, XmlTest for a test tag, XmlClass and XmlInclude for classes and method includes, plus setters for parallel, thread-count, parameters, groups and the config failure policy. You hand the assembled suites to a TestNG instance with setXmlSuites and call run(). The reason to do this is that XML is static and your run scope usually is not.

Sharding is the main driver: with four thousand tests across eight CI runners, a generated suite per runner covers the set exactly once, whereas hand-maintained shard XML files drift the moment somebody adds a class and coverage silently drops. Other real cases are running only tests touched by the current diff, expanding a suite per tenant discovered from a database at start-up, and letting an internal test-launcher service build a suite from a UI selection. The other half of the answer is exit codes, because a programmatic runner has to decide what fails the build.

TestNG.getStatus() returns a bitmask after run(), and the bit you must not ignore is the one indicating that no tests ran at all: a filter that matched nothing otherwise exits zero and publishes a green pipeline. Check for failures, decide deliberately whether skips are fatal (they usually should be), and treat an empty run as a hard error. Keep useDefaultListeners on unless you have replaced reporting entirely, set the output directory explicitly so CI can archive it, and remember that a programmatic runner still respects listeners discovered through META-INF/services, which is often how a shared framework jar keeps behaving the same whether the suite came from XML or from code.

public final class ShardRunner {

  public static void main(String[] args) {
    int total = Integer.getInteger("shard.total", 1);
    int index = Integer.getInteger("shard.index", 0);

    XmlSuite suite = new XmlSuite();
    suite.setName("shard-" + index);
    suite.setParallel(XmlSuite.ParallelMode.CLASSES);
    suite.setThreadCount(6);
    suite.setConfigFailurePolicy(XmlSuite.FailurePolicy.CONTINUE);
    suite.setParameters(Map.of("env", System.getProperty("env", "staging")));

    XmlTest test = new XmlTest(suite);
    test.setName("slice");
    List<XmlClass> classes = new ArrayList<>();
    List<String> all = TestScanner.allTestClasses("tests");
    for (int i = index; i < all.size(); i += total) classes.add(new XmlClass(all.get(i)));
    test.setXmlClasses(classes);

    TestNG ng = new TestNG();
    ng.setXmlSuites(List.of(suite));
    ng.setOutputDirectory("target/testng-shard-" + index);
    ng.run();

    int status = ng.getStatus();
    if (status != 0) {
      System.err.println("suite status bitmask=" + status);   // 0 means clean
      System.exit(1);
    }
  }
}

Key Points

  • XmlSuite, XmlTest, XmlClass and XmlInclude mirror every XML element
  • Generated shards stay correct when someone adds a test class
  • getStatus() after run() carries a no-tests-ran bit; never exit zero on it
  • ServiceLoader listeners still apply to a programmatic run
Q30

A long TestNG suite dies with OutOfMemoryError around the seventieth percentile of tests. How do you diagnose it?

AdvancedMemory

Answer

Start by separating two different memory pools, because the fix differs entirely. JVM heap holds TestNG and your framework. Container memory also holds the browsers, and sixteen Chrome processes at roughly a few hundred megabytes each will get a pod OOM-killed while the JVM heap sits comfortable, which shows up as the run vanishing with exit code 137 rather than as a Java stack trace.

Confirm which one you have before touching anything. For genuine heap exhaustion, the structural cause is that TestNG retains an ITestResult for every invocation until the run ends, because IReporter implementations receive the complete ISuite list at the finish. Each result holds the parameters, the throwable with its full stack trace, and any attributes you attached.

Add a data-driven suite with tens of thousands of rows and that alone is significant. What usually tips it over is framework code: base64 screenshots pushed into ExtentReports, which builds the entire report model in memory until flush; page source or full HTTP response bodies stashed with ITestResult.setAttribute; a listener accumulating results in a static list; and ThreadLocals never removed, so every WebDriver ever created stays strongly reachable through the pool threads for the whole run. Apache POI is another regular offender, since XSSFWorkbook materialises a whole workbook to serve a data provider.

Practical fixes: run with -XX:+HeapDumpOnOutOfMemoryError and a HeapDumpPath, then open the dump and look at dominators, which will point straight at the real holder. Write screenshots to disk and store the path, not the bytes. Flush reports incrementally.

Switch large providers to Iterator so rows stream. Call ThreadLocal.remove in teardown. Raise -Xmx in the Surefire argLine only after you know what is being retained, and use forkCount to reset the JVM between modules on very long runs.

<!-- Surefire: bound the heap and always capture a dump -->
<configuration>
  <forkCount>1</forkCount>
  <reuseForks>false</reuseForks>
  <argLine>-Xmx3g -XX:+HeapDumpOnOutOfMemoryError
           -XX:HeapDumpPath=target/heap.hprof -Xlog:gc*:file=target/gc.log</argLine>
</configuration>

// BAD: bytes retained in the report model for the entire run
extentTest.addScreenCaptureFromBase64String(Base64.getEncoder().encodeToString(png));

// GOOD: bytes on disk, a path in the report
Path p = Paths.get("target/shots", result.getName() + ".png");
Files.write(p, png);
extentTest.addScreenCaptureFromPath(p.toString());

// BAD: page source pinned to a result that survives until the suite ends
result.setAttribute("pageSource", driver.getPageSource());

// Stream large data sets instead of materialising Object[][]
@DataProvider(name = "rows")
public Iterator<Object[]> rows() { return CsvRows.stream("data/orders.csv").iterator(); }

Key Points

  • Exit code 137 means the container was killed, not a Java heap problem
  • ITestResult objects live until IReporter runs at the very end of the suite
  • Base64 screenshots and stored page source are the usual heap dominators
  • Heap dump on OOM plus a dominator view beats guessing at -Xmx
💡 Pro Tip: Ask which pool ran out before proposing a fix. Splitting JVM heap from browser RSS is the answer that marks an SDET who has actually owned a nightly run.
Q31

Design a flakiness strategy for a 3000-test TestNG suite that a release manager will actually trust.

AdvancedFlakiness

Answer

Start by refusing the shortcut. Blanket retries plus a merged pass rate is the common answer and it is the wrong one, because it converts intermittent product bugs, which are usually the expensive ones, into invisible ones. A defensible design has four layers.

Measure first: every result goes into a small store keyed by fully qualified method name, with pass, fail and retried counts per day, which you can build from a listener plus ITestResult.wasRetried() with no extra infrastructure. A test with a nonzero flake rate over a rolling week is flaky by definition, and this ends the argument about whether it is the test or the environment. Second, retry narrowly: an IRetryAnalyzer capped at one attempt that returns false for AssertionError and true only for known infrastructure exceptions such as WebDriverException or a socket timeout.

Report retried passes in their own bucket so the primary pass rate never absorbs them. Third, quarantine with an expiry: a flaky test moves to a quarantine group excluded from the blocking pipeline but still executed in a separate nightly job, with an owner and a date, and the build fails if a quarantined test has sat there past its date. Without the expiry, quarantine becomes a graveyard and your coverage quietly halves.

Fourth, remove the causes rather than the symptoms. In practice the recurring ones are Thread.sleep instead of explicit waits, tests that share a login account and race each other, ordering assumptions that only held when the suite ran serially, uncleaned data so the second run of the day sees yesterday's records, and animation or lazy-loading timing on the front end. Present it as a dashboard with three numbers: pass rate excluding retries, flake rate, and quarantine size with age. Those three make the state of the suite honest.

public class FlakeTracker implements ITestListener {

  private static final Path DB = Paths.get("target/flake-history.csv");

  @Override public void onTestSuccess(ITestResult r) { record(r, r.wasRetried() ? "FLAKY" : "PASS"); }
  @Override public void onTestFailure(ITestResult r) { record(r, "FAIL"); }

  private void record(ITestResult r, String outcome) {
    String line = String.join(",", LocalDate.now().toString(),
        r.getMethod().getQualifiedName(), outcome,
        String.valueOf(r.getEndMillis() - r.getStartMillis())) + "\n";
    try { Files.writeString(DB, line, CREATE, APPEND); } catch (IOException ignored) { }
  }

  @Override
  public void onFinish(ITestContext ctx) {
    long flaky = ctx.getPassedTests().getAllResults().stream()
        .filter(ITestResult::wasRetried).count();
    // the number that goes on the dashboard, never merged into the pass rate
    Reporter.log("FLAKY_PASSES=" + flaky, true);
  }
}

<!-- blocking pipeline excludes quarantine; a nightly job runs ONLY quarantine -->
<run>
  <exclude name="quarantine"/>
</run>

Key Points

  • Track flake rate per method; wasRetried() gives it to you for free
  • Retry once, only on infrastructure exceptions, never on AssertionError
  • Quarantine needs an owner and an expiry date or coverage evaporates
  • Publish pass rate excluding retries, flake rate and quarantine age
Q32

How do you wire dependency injection into TestNG with Guice or Spring, and what breaks under parallel execution?

AdvancedSpring And Guice

Answer

TestNG normally instantiates a test class through its no-argument constructor, which is why @Autowired and @Inject fields are simply null if you do nothing. Guice support is native: put @Guice(modules = AppModule.class) on the test class and TestNG builds the instance through an injector so constructor and field injection work. @Guice(moduleFactory = EnvModuleFactory.class) with an IModuleFactory lets you choose modules at runtime, which is how one suite targets staging or production bindings from a system property. The parent-module attribute on the suite element creates a suite-wide injector whose bindings are shared by every test class, so an expensive singleton such as an HTTP client or a database pool is built once.

Spring works differently: spring-test ships AbstractTestNGSpringContextTests and AbstractTransactionalTestNGSpringContextTests, and your class extends one of them alongside @ContextConfiguration or @SpringBootTest. The base class carries an alwaysRun @BeforeClass that prepares the test instance, so if you skip the base class and try to hand-roll it, injection silently does not happen. Parallel execution is where the interesting failures live.

Spring caches application contexts by configuration key and shares them across threads, which is normally a benefit but means any bean holding mutable per-test state is now shared; @DirtiesContext forces a rebuild and, in a parallel run, can tear the context down while another thread is mid-test. Transactional tests bound to a thread-local transaction manager do not survive work handed to another thread. On the Guice side, a suite-scoped singleton is genuinely shared, so a singleton WebDriver defeats parallelism exactly like a static field, and the correct shape is a provider returning a per-thread instance. Interviewers usually finish by asking whether you would inject the WebDriver at all, and the safe answer is that drivers stay in a ThreadLocal factory while injection handles stateless collaborators.

// Guice: TestNG builds the instance through the injector
@Guice(modules = ApiModule.class)
public class QuoteApiTests {

  @Inject private QuoteClient client;      // null without @Guice

  @Test
  public void quoteReturnsGst() {
    Assert.assertEquals(client.quote("SKU-1001").gstPercent(), 18);
  }
}

public class ApiModule extends AbstractModule {
  @Override protected void configure() {
    bind(QuoteClient.class).toInstance(new QuoteClient(System.getProperty("env", "staging")));
    // per-thread, NOT a singleton: a shared driver defeats parallel execution
    bind(WebDriver.class).toProvider(DriverFactory::get);
  }
}

<!-- suite-wide injector shared by every class -->
<suite name="api" parent-module="config.RootModule" parallel="classes" thread-count="6"/>

// Spring: you must extend the base class or nothing is injected
@SpringBootTest
public class PricingTests extends AbstractTestNGSpringContextTests {
  @Autowired private PricingService pricing;

  @Test public void slabIsApplied() { Assert.assertEquals(pricing.slab(120000), "GOLD"); }
}

Key Points

  • @Guice on the class, moduleFactory for runtime choices, parent-module for suite scope
  • Spring needs AbstractTestNGSpringContextTests or injection silently does nothing
  • Cached Spring contexts are shared across threads; @DirtiesContext is hostile to parallel runs
  • Never bind WebDriver as a singleton; provide it per thread
Q33

Give the honest comparison of TestNG and JUnit 5 for a new Java automation project in 2026.

AdvancedComparisons

Answer

Feature by feature, JUnit 5 has caught up on most of what made TestNG distinctive. @ParameterizedTest with @MethodSource, @CsvSource and @ArgumentsSource covers data providers. @Tag replaces groups. Parallel execution exists through junit-platform.properties with junit.jupiter.execution.parallel.enabled and the per-class and per-method modes, plus @Execution and @ResourceLock, which is a more precise concurrency model than a single thread-count. Extensions (BeforeEachCallback, TestWatcher, InvocationInterceptor, ParameterResolver) are a cleaner and better-documented version of the TestNG listener SPI, and JUnit 5 has first-class support in every IDE and build tool.

What TestNG still does better is suite orchestration from outside the code: testng.xml lets a QA lead recompose a run, override parameters per browser and change parallelism without touching Java, and JUnit 5 has no equally convenient equivalent, since the Suite engine and tag filters are less expressive. TestNG also has dependsOnMethods with skip semantics, which JUnit deliberately does not offer because it discourages dependent tests, and it has testng-failed.xml, which is a genuinely useful rerun artefact. For a greenfield project in 2026 the defensible recommendation is JUnit 5 for unit and service tests and either framework for UI, with TestNG a reasonable pick when non-developers own run composition.

The reality in Indian hiring is the other half of the answer: the large Selenium hybrid frameworks at service organisations and enterprise QA teams were built on TestNG between roughly 2015 and 2020, they carry thousands of tests, and nobody funds a rewrite, so TestNG skills stay in demand regardless of what is technically newer. Say both halves. Candidates who only recite JUnit 5 superiority sound like they have never inherited a suite.

Key Points

  • JUnit 5 matches groups, data providers and parallelism with @Tag, @ParameterizedTest and platform properties
  • TestNG keeps the edge on external suite composition through testng.xml
  • dependsOnMethods and testng-failed.xml have no direct JUnit 5 equivalent
  • Existing Indian Selenium estates keep TestNG demand alive independent of merit
💡 Pro Tip: Answer with a recommendation and a caveat, not a winner. Interviewers at product firms want to hear that you would pick JUnit 5 for greenfield and still maintain TestNG competently.
Q34

What has changed across the recent TestNG 7.x line, and what breaks when upgrading a 6.x framework?

AdvancedVersions

Answer

The change that forces a decision is the Java baseline. TestNG 7.5 was the last release that ran on Java 8, and 7.6 moved the minimum to Java 11, so a team still pinned to a Java 8 runtime cannot take newer releases at all. That matters in practice because older TestNG on a modern JDK is the other half of the same problem: 6.x frameworks tend to fail on JDK 17 and later with reflection and illegal-access errors, so upgrading the JDK and upgrading TestNG usually have to happen together, along with maven-surefire-plugin 3.x.

Compilation breaks during a 6.x to 7.x move are limited but real. The numbered annotation transformer interfaces, IAnnotationTransformer2 and IAnnotationTransformer3, were folded back into a single IAnnotationTransformer, so any framework that implemented the numbered variants stops compiling and needs its transform overloads merged. Various org.testng.internal classes that people had reached into are no longer where they were, which is a fair reminder that internal means internal.

The suite DTD reference moved to https, and TestNG now refuses an http fetch unless you explicitly opt back in with the testng.dtd.http system property, which surprises teams behind a corporate proxy whose builds suddenly stall or fail at parse time. Within the 7.x line the useful additions are quality-of-life: ITestResult.wasRetried() so listeners can distinguish a retried pass from a clean one, a dedicated onTestFailedWithTimeout listener callback separate from ordinary failures, data providers that accept a one-dimensional Object[] for single-argument test methods, and the removal of the old built-in JUnit compatibility mode, so a suite still setting the junit attribute has to move to the JUnit engine instead. Upgrade path in practice: bump TestNG and Surefire together, run the suite serially once, then re-enable parallelism.

<!-- Bump together: JDK, TestNG and Surefire -->
<properties>
  <maven.compiler.release>17</maven.compiler.release>
</properties>

<dependency>
  <groupId>org.testng</groupId>
  <artifactId>testng</artifactId>
  <version>7.11.0</version>
  <scope>test</scope>
</dependency>

<!-- 6.x code that stops compiling on 7.x -->
// public class T implements IAnnotationTransformer2 { ... }   // interface is gone
public class T implements IAnnotationTransformer {
  @Override public void transform(ITestAnnotation a, Class<?> c, Constructor<?> ct, Method m) { }
  @Override public void transform(IConfigurationAnnotation a, Class<?> c, Constructor<?> ct, Method m) { }
  @Override public void transform(IDataProviderAnnotation a, Method m) { }
}

<!-- Use the https DTD; the http one is refused unless you opt back in -->
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<!-- escape hatch, avoid it: -Dtestng.dtd.http=true -->

Key Points

  • 7.5 was the last Java 8 release; 7.6 raised the baseline to Java 11
  • 6.x on JDK 17 fails on reflection, so JDK and TestNG upgrades travel together
  • IAnnotationTransformer2 and 3 merged back into IAnnotationTransformer
  • The DTD is https now; http needs an explicit opt-in and stalls behind proxies
Q35

Your TestNG suite hits a Selenium Grid at thread-count 20 and starts failing. What breaks first and how do you fix it?

AdvancedProduction Failures

Answer

The first thing to break is session supply, and the error text is a session not created message, usually with a note that the request timed out waiting for a node. Grid 4 queues new session requests rather than rejecting them outright, so raising thread-count past the total node capacity does not increase throughput, it converts parallelism into queue wait. That queue wait then collides with your other timeouts: the request eventually breaches the Grid session request timeout, and any @Test timeOut you set fires while the thread is still parked waiting for a browser, which produces failures that look like slow application behaviour and are actually pure capacity.

The correct sizing rule is that thread-count must be at or below the sum of node max sessions, and if a parallel data provider is in play you have to include data-provider-thread-count in that arithmetic. The second failure is leaked sessions. A method abandoned by TestNG timeOut leaves a thread blocked in a socket read holding a live browser, and if driver.quit() lives only in a happy path or in an @AfterMethod without alwaysRun, that session survives until the Grid session idle timeout expires, so effective capacity shrinks as the run progresses and the failure rate climbs towards the end.

Third is the retry storm: a suite-wide retry analyzer that retries on WebDriverException will faithfully retry every capacity failure, doubling the load on an already saturated Grid. The fix set is boring and effective: size threads against node capacity, quit in an alwaysRun teardown with ThreadLocal.remove, exclude capacity errors from retry eligibility or add backoff, set the Grid session idle timeout low enough that leaks self-heal, and scale horizontally by sharding across CI runners rather than pushing one JVM to a higher thread-count.

// Capacity arithmetic, not guesswork:
//   nodes x max-sessions >= thread-count x (parallel data provider fan-out)
//   4 nodes x 5 sessions = 20  ->  thread-count 20 with NO parallel providers

<suite name="grid" parallel="classes" thread-count="16" data-provider-thread-count="1">

// Teardown that survives an abandoned or failed test
@AfterMethod(alwaysRun = true)
public void quitDriver() {
  try {
    WebDriver d = DriverFactory.peek();
    if (d != null) d.quit();
  } catch (WebDriverException ignored) {
    // session already gone: never let teardown mask the real failure
  } finally {
    DriverFactory.clear();          // ThreadLocal.remove, or the pool thread pins it
  }
}

// Do NOT retry capacity errors: it doubles load on a saturated Grid
@Override
public boolean retryMethod(ITestResult result) {
  Throwable t = result.getThrowable();
  if (t instanceof SessionNotCreatedException) return false;
  return t instanceof WebDriverException;
}

Key Points

  • Grid 4 queues session requests, so excess threads become wait time, not throughput
  • thread-count must fit inside nodes multiplied by max sessions
  • Timed-out methods leak live sessions and shrink capacity as the run proceeds
  • Retrying session-not-created errors turns saturation into a retry storm
💡 Pro Tip: Bring the arithmetic. Saying nodes times max-sessions must exceed thread-count plus data-provider fan-out is the sentence that ends this question in your favour.

Companies Hiring TestNG

TCS
Infosys
Wipro
Cognizant
Accenture
HCLTech
Flipkart
Paytm

Salary Insights

Average in India
₹5-18 LPA

Frequently Asked Questions

What salary can I expect in India with TestNG on my CV?

TestNG on its own is a line item, not a salary driver; what pays is the automation stack it sits inside. Manual testers moving into automation with a working Selenium plus TestNG plus Maven framework typically enter at ₹4-7 LPA at service organisations such as TCS, Infosys, Wipro, Cognizant, Accenture and HCLTech. SDETs with three to six years who can explain the parallel model, ThreadLocal driver management, listeners and CI integration usually land ₹9-16 LPA, and product companies in Bengaluru, Hyderabad, Pune and Gurugram pay at the upper end. Beyond seven years the title shifts to SDET lead or automation architect at roughly ₹18-30 LPA, but at that level the interview is about framework design, flakiness economics and pipeline ownership rather than annotations. The multipliers that actually move an offer are Java depth, API automation with RestAssured, CI ownership in Jenkins or GitHub Actions, and the ability to show a suite you made faster or less flaky with numbers attached.

How long does it take to prepare for a TestNG interview?

If you already write Java and have used Selenium, two focused weeks is realistic. Week one covers lifecycle order, testng.xml structure, groups, data providers, dependencies, assertions and the Maven or Gradle wiring, and it should be spent building a small suite rather than reading, because panels ask what happens when a @BeforeMethod throws and only people who have seen it answer cleanly. Week two covers the parts that decide mid-level offers: the parallel model, ThreadLocal drivers, listeners, retry analyzers with an annotation transformer, factories and timeouts. If you are starting from manual testing without Java, plan on three to four months, because the Java gap is the real gap and interviewers find it within minutes when they ask about collections, streams or exception handling in the same conversation. The most effective single exercise is to take an existing serial suite, make it run parallel, and fix everything that breaks. That experience answers about a third of the technical questions on its own.

What is expected from a fresher versus someone with five years of experience?

A fresher is expected to know the annotation lifecycle cold, write a testng.xml from memory, explain groups and data providers, use assertions correctly (including the assertEquals actual-then-expected argument order), and run a suite through Maven. Getting the priority and dependsOnMethods distinction right already puts a fresher ahead of most of the pool. At five years the questions change completely: nobody asks what @BeforeSuite does. They ask why the nightly run takes ninety minutes and how you would halve it, how you manage drivers under parallel equals methods, how retries are applied without annotating four hundred methods, how you shard across CI runners, and what you did about a suite that reported green while skipping forty tests. Experienced candidates are also expected to have opinions with reasons: when retries are dishonest, when a factory beats a data provider, why ordering dependencies block parallelism. Interviewers at that level are checking whether you owned a framework or only added test methods to one, and the vocabulary gives it away quickly.

Is TestNG still worth learning in 2026, or should I go straight to Playwright?

Both, in that order of urgency depending on what you are targeting. Playwright with TypeScript or Python is where a lot of new UI automation is being written, and its own runner makes TestNG irrelevant in that stack. But the installed base matters more than the trend line when you are looking for a job: the large Selenium hybrid frameworks running at Indian service organisations, banks, insurers and enterprise QA teams were built on Java plus TestNG, they hold thousands of tests, and nobody is funding a rewrite. Those suites need people who can maintain, parallelise and stabilise them, and that demand is steady rather than fashionable. The practical path is to be strong in Java plus TestNG plus Selenium plus RestAssured, which opens the widest set of Indian openings today, and add Playwright as a second stack so you are not stranded when a product team starts fresh. If you can only learn one thing well this quarter, learn Java properly, because every TestNG interview turns into a Java interview about twenty minutes in.

Should I learn TestNG or JUnit 5 first?

It depends on the role you are aiming at. For QA and SDET positions in India, TestNG appears in more job descriptions, because it is the harness under most existing Selenium frameworks and because testng.xml suits teams where a QA lead composes runs without touching code. For backend developer roles and for any greenfield Java project, JUnit 5 is the default, and it has closed the gap with @ParameterizedTest, @Tag, extensions and platform-level parallel configuration. The good news is that the transfer cost is low: lifecycle scoping, parameterised data, tagging and parallel isolation are the same concepts under different names, so a week is enough to move either way. If you are optimising for interview coverage, learn TestNG deeply and know the JUnit 5 equivalents by name, because a common question is exactly how you would express a data provider or a group in JUnit 5. Being fluent in the mapping reads as broader engineering judgement than being loyal to one framework.

Which companies in India hire for TestNG skills, and how do their interviews differ?

Two distinct markets. Service organisations including TCS, Infosys, Wipro, Cognizant, Accenture and HCLTech hire in volume for client automation work, and their interviews weight breadth: annotations, testng.xml, Selenium waits, a Java coding round on strings or collections, and a framework walkthrough where you explain your page object structure. Product and platform teams, including consumer companies such as Flipkart and fintechs such as Paytm, hire fewer people at a higher bar, and their rounds look like engineering interviews with a testing focus: design a framework for a service with fifty endpoints, debug a flaky parallel suite from a real stack trace, discuss CI sharding and pipeline economics. Prepare differently for each. For the service track, be able to talk through your framework fluently for ten minutes. For the product track, bring a specific story with numbers, for example that you cut a regression run from ninety minutes to twenty-five by moving login to API-based session injection and switching the suite to parallel equals classes.

Introduction

TestNG is the harness that most Java automation suites in India still run on. It began as an answer to the limits of JUnit 3, but in 2026 its real job is orchestration: deciding which of your four thousand Selenium or RestAssured tests execute, in what order, on how many threads, with which data set, and what happens when one of them dies halfway through a release pipeline. The test bodies themselves are usually WebDriver calls, RestAssured requests, or plain Java service calls. TestNG supplies the annotations, the testng.xml suite descriptor, groups, data providers, listeners, retry hooks, and the parallel execution model that CI actually depends on.

Interviews for SDET and automation roles rarely stop at reciting annotations. Panels at service firms and product QA teams push on the parts that break in real pipelines: why priority does not guarantee execution order, why a static WebDriver field destroys a method-parallel run, what configfailurepolicy does when a @BeforeMethod throws, how IRetryAnalyzer gets applied globally through IAnnotationTransformer, and how you shard a suite across CI runners without duplicating coverage. A candidate who has only pressed Run in IntelliJ gets exposed within ten minutes. A candidate who has debugged a flaky nightly suite at 2 AM answers with different vocabulary, and interviewers hear the difference immediately.

This guide works through the 35 TestNG questions that decide interviews in 2026, ordered basic to advanced. The basic section nails down lifecycle order, suite XML, assertions, groups, data providers and dependency semantics. The intermediate section covers the concurrency model, ThreadLocal driver management, listeners, factories, timeouts and interceptors, which is where most mid-level candidates lose the offer. The advanced section covers programmatic suite construction, heap behaviour on long runs, a defensible flakiness strategy, Guice and Spring wiring, the honest TestNG versus JUnit 5 comparison, and what changed across recent TestNG 7.x releases.

Ready to practice TestNG interviews?

Don't just read, practice these TestNG questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview