From ad3a35ab5c0ddbbe274ec704e211a2897523b21f Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sun, 28 Jun 2026 17:22:21 +0200 Subject: [PATCH 1/3] Delegate behaviour-identical assertions to AssertJ Port the AssertJ-backed assertion implementation from testng-team/testng#3278 into this standalone artifact, so it can be dropped from the parent PR. - Assert: delegate assertNull/assertNotNull/assertSame/assertNotSame and assertThrows to AssertJ (behaviour-identical, only the failure message differs); remove the now-unused failSame/failNotSame helpers. Equality comparison intentionally keeps TestNG semantics (documented NOTE). - Mark org.testng.Assert @Deprecated (recommend AssertJ); add @SuppressWarnings("deprecation") on Assertion. - pom: move assertj-core to compile scope (the main code depends on it). - docs: add MIGRATING_ASSERTIONS.md and link it from the README. expectThrows is kept unchanged (it returns the caught exception, which AssertJ's fluent API does not expose). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 +- docs/MIGRATING_ASSERTIONS.md | 158 ++++++++++++++++++ pom.xml | 14 +- src/main/java/org/testng/Assert.java | 77 ++++----- .../java/org/testng/asserts/Assertion.java | 1 + 5 files changed, 208 insertions(+), 50 deletions(-) create mode 100644 docs/MIGRATING_ASSERTIONS.md diff --git a/README.md b/README.md index 2cdb2bb..e59f4a2 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ evolve and be released independently. * JDK 11 (or higher) -The assertion classes themselves depend only on the JDK. TestNG is required at **test scope** only -(to run the assertion tests via the TestNG runner). +`org.testng.Assert` delegates the behaviour-identical assertions (`assertNull`/`assertNotNull`/ +`assertSame`/`assertNotSame`, `assertThrows`) to [AssertJ](https://assertj.github.io/doc/), so +`assertj-core` is the only runtime dependency (brought transitively). TestNG is required at **test +scope** only (to run the assertion tests via the TestNG runner). ## Usage @@ -31,6 +33,8 @@ If you previously relied on `org.testng.Assert` / `org.testng.asserts.*` coming `org.testng:testng`, add `org.testng:testng-asserts` explicitly. For new code or larger refactoring efforts, [AssertJ](https://assertj.github.io/doc/) is recommended. +See [docs/MIGRATING_ASSERTIONS.md](docs/MIGRATING_ASSERTIONS.md) for the migration guide, including +the automated OpenRewrite recipe. ## Building diff --git a/docs/MIGRATING_ASSERTIONS.md b/docs/MIGRATING_ASSERTIONS.md new file mode 100644 index 0000000..326fc7b --- /dev/null +++ b/docs/MIGRATING_ASSERTIONS.md @@ -0,0 +1,158 @@ +# Migrating away from `org.testng.Assert` + +`org.testng.Assert` is **deprecated**. TestNG no longer wants to maintain its own assertion library +and instead recommends a dedicated one such as [AssertJ](https://assertj.github.io/doc/). + +This is part of a longer plan: + +- [testng-asserts#15](https://github.com/testng-team/testng-asserts/issues/15) — deprecate the + internal assertion API and let users choose their preferred library. +- [testng#3197](https://github.com/testng-team/testng/issues/3197) — make the `testng-asserts` + module optional. + +`org.testng.Assert` now lives in the standalone `org.testng:testng-asserts` artifact, separate from +the main `org.testng:testng` jar. Migrating now keeps your test suite working across that transition. + +## What replaces `org.testng.Assert`? + +[AssertJ](https://assertj.github.io/doc/). Its fluent API covers everything `org.testng.Assert` +offers, with better failure messages: + +```java +// Before — org.testng.Assert +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +assertEquals(actual, expected); +assertTrue(result); + +// After — AssertJ +import static org.assertj.core.api.Assertions.assertThat; + +assertThat(actual).isEqualTo(expected); +assertThat(result).isTrue(); +``` + +> **Note the parameter order.** `org.testng.Assert` uses `(actual, expected)`. AssertJ reads +> `assertThat(actual).isEqualTo(expected)`. The automated recipe below handles this for you. + +Add AssertJ to your build: + +```xml + + + org.assertj + assertj-core + test + +``` + +```kotlin +// Gradle +testImplementation("org.assertj:assertj-core:") +``` + +## Automated migration with OpenRewrite + +[OpenRewrite](https://docs.openrewrite.org/) provides a recipe that rewrites **the entire** +`org.testng.Assert` API to AssertJ. The full Assert coverage is available since +[rewrite-testing-frameworks v3.39.0](https://github.com/openrewrite/rewrite-testing-frameworks/releases/tag/v3.39.0). + +Recipe: `org.openrewrite.java.testing.testng.TestNgToAssertj` — *"Migrate TestNG assertions to +AssertJ"*. It handles both qualified (`Assert.assertEquals(...)`) and static-import +(`assertEquals(...)`) usages. + +### Gradle + +Apply the OpenRewrite plugin and activate the recipe: + +```kotlin +plugins { + id("org.openrewrite.rewrite") version "" +} + +dependencies { + rewrite("org.openrewrite.recipe:rewrite-testing-frameworks:<3.39.0-or-newer>") +} + +rewrite { + activeRecipe("org.openrewrite.java.testing.testng.TestNgToAssertj") +} +``` + +Run it: + +```bash +./gradlew rewriteRun +``` + +### Maven + +```xml + + org.openrewrite.maven + rewrite-maven-plugin + + + + org.openrewrite.java.testing.testng.TestNgToAssertj + + + + + org.openrewrite.recipe + rewrite-testing-frameworks + + + + +``` + +Run it: + +```bash +mvn rewrite:run +``` + +> Check the [OpenRewrite docs](https://docs.openrewrite.org/) for the current plugin and recipe +> versions. After the recipe runs, add the `org.assertj:assertj-core` test dependency (shown above) +> and run your test suite to confirm everything passes. + +## Manual migration + +If you prefer to migrate by hand, the most common mappings are: + +| `org.testng.Assert` | AssertJ | +| --------------------------------------- | ------------------------------------------------ | +| `assertEquals(actual, expected)` | `assertThat(actual).isEqualTo(expected)` | +| `assertNotEquals(actual, expected)` | `assertThat(actual).isNotEqualTo(expected)` | +| `assertTrue(condition)` | `assertThat(condition).isTrue()` | +| `assertFalse(condition)` | `assertThat(condition).isFalse()` | +| `assertNull(object)` | `assertThat(object).isNull()` | +| `assertNotNull(object)` | `assertThat(object).isNotNull()` | +| `assertSame(actual, expected)` | `assertThat(actual).isSameAs(expected)` | +| `assertNotSame(actual, expected)` | `assertThat(actual).isNotSameAs(expected)` | +| `assertThrows(Type.class, runnable)` | `assertThatThrownBy(runnable).isInstanceOf(Type.class)` | +| `fail(message)` | `fail(message)` (`org.assertj.core.api.Assertions.fail`) | + +See the [AssertJ documentation](https://assertj.github.io/doc/) for the full API. + +## Packaging notes + +`org.testng.Assert` is no longer bundled in the main `org.testng:testng` jar. To keep using it, add +the standalone artifact, which brings AssertJ transitively: + +```xml + + org.testng + testng-asserts + test + +``` + +> **OSGi:** `testng-asserts` currently ships as a plain JAR, not an OSGi bundle. `org.testng.Assert` +> and `org.testng.FileAssert` share the `org.testng` package with the core classes exported by the +> `testng` bundle, so giving `testng-asserts` its own bundle would create a split package. Proper +> OSGi support is tracked as a follow-up +> ([testng#3197](https://github.com/testng-team/testng/issues/3197)); OSGi users should prefer +> migrating to AssertJ. diff --git a/pom.xml b/pom.xml index a792cea..1182bce 100644 --- a/pom.xml +++ b/pom.xml @@ -63,20 +63,20 @@ - - org.testng - testng - [7.10.2,) - test - org.assertj assertj-core 3.27.7 + + + org.testng + testng + [7.10.2,) test diff --git a/src/main/java/org/testng/Assert.java b/src/main/java/org/testng/Assert.java index 11370c9..86ab323 100644 --- a/src/main/java/org/testng/Assert.java +++ b/src/main/java/org/testng/Assert.java @@ -1,7 +1,8 @@ package org.testng; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.testng.internal.EclipseInterface.ASSERT_EQUAL_LEFT; -import static org.testng.internal.EclipseInterface.ASSERT_LEFT2; import static org.testng.internal.EclipseInterface.ASSERT_MIDDLE; import static org.testng.internal.EclipseInterface.ASSERT_RIGHT; import static org.testng.internal.EclipseInterface.ASSERT_UNEQUAL_LEFT; @@ -34,7 +35,12 @@ * assertFalse(var.equals(null))}. * * @author Alexandru Popescu + * @deprecated TestNG no longer maintains its own assertion library. Use a dedicated assertion + * library such as AssertJ instead. The OpenRewrite + * recipe {@code org.openrewrite.java.testing.testng.TestNgToAssertj} migrates the whole class + * automatically; see {@code docs/MIGRATING_ASSERTIONS.md} for details. */ +@Deprecated public class Assert { public static final String ARRAY_MISMATCH_TEMPLATE = @@ -169,6 +175,17 @@ private static boolean areNotEqualImpl(Object actual, Object expected) { return !expected.equals(actual); } + // NOTE: equality comparison is intentionally NOT delegated to AssertJ; it keeps TestNG's own + // semantics, which differ from AssertJ on purpose: + // - equality must be symmetric: both expected.equals(actual) and actual.equals(expected) must + // hold (AssertJ only checks one direction); + // - a broken equals() returning true for null does not make the values equal here, whereas + // AssertJ trusts equals(); + // - arrays nested inside collections/maps/iterables are compared by reference (use + // assertEqualsDeep for value comparison), whereas AssertJ compares them deeply. + // These behaviours are covered by regression tests (e.g. GITHUB-2483, GITHUB-2490, GITHUB-2500, + // ArrayEqualityAssertTest); only behaviour-identical assertions (assertNull/NotNull/Same/NotSame) + // are delegated to AssertJ. private static boolean areEqualImpl(Object actual, Object expected) { if ((expected == null) && (actual == null)) { return true; @@ -1488,13 +1505,8 @@ public static void assertNotNull(Object object) { * @param message the assertion error message */ public static void assertNotNull(Object object, String message) { - if (object == null) { - String formatted = ""; - if (message != null) { - formatted = message + " "; - } - fail(formatted + "expected object to not be null"); - } + // Delegated to AssertJ (behaviour-identical, only the failure message differs). + assertThat(object).as(message).isNotNull(); } /** @@ -1515,9 +1527,8 @@ public static void assertNull(Object object) { * @param message the assertion error message */ public static void assertNull(Object object, String message) { - if (object != null) { - failNotSame(object, null, message); - } + // Delegated to AssertJ (behaviour-identical, only the failure message differs). + assertThat(object).as(message).isNull(); } /** @@ -1529,10 +1540,8 @@ public static void assertNull(Object object, String message) { * @param message the assertion error message */ public static void assertSame(Object actual, Object expected, String message) { - if (expected == actual) { - return; - } - failNotSame(actual, expected, message); + // Delegated to AssertJ (behaviour-identical, only the failure message differs). + assertThat(actual).as(message).isSameAs(expected); } /** @@ -1554,9 +1563,8 @@ public static void assertSame(Object actual, Object expected) { * @param message the assertion error message */ public static void assertNotSame(Object actual, Object expected, String message) { - if (expected == actual) { - failSame(actual, expected, message); - } + // Delegated to AssertJ (behaviour-identical, only the failure message differs). + assertThat(actual).as(message).isNotSameAs(expected); } /** @@ -1570,22 +1578,6 @@ public static void assertNotSame(Object actual, Object expected) { assertNotSame(actual, expected, null); } - private static void failSame(Object actual, Object expected, String message) { - String formatted = ""; - if (message != null) { - formatted = message + " "; - } - fail(formatted + ASSERT_LEFT2 + expected + ASSERT_MIDDLE + actual + ASSERT_RIGHT); - } - - private static void failNotSame(Object actual, Object expected, String message) { - String formatted = ""; - if (message != null) { - formatted = message + " "; - } - fail(formatted + ASSERT_EQUAL_LEFT + expected + ASSERT_MIDDLE + actual + ASSERT_RIGHT); - } - private static void failNotEquals(Object actual, Object expected, String message) { fail(format(actual, expected, message, true)); } @@ -2386,25 +2378,27 @@ public static void assertThrows(ThrowingRunnable runnable) { /** * Asserts that {@code runnable} throws an exception of type {@code throwableClass} when executed. * If it does not throw an exception, an {@link AssertionError} is thrown. If it throws the wrong - * type of exception, an {@code AssertionError} is thrown describing the mismatch; the exception - * that was actually thrown can be obtained by calling {@link AssertionError#getCause}. + * type of exception, an {@code AssertionError} is thrown describing the mismatch. To obtain the + * exception that was actually thrown, use {@link #expectThrows} instead. * * @param throwableClass the expected type of the exception * @param the expected type of the exception * @param runnable A function that is expected to throw an exception when invoked * @since 6.9.5 */ - @SuppressWarnings("ThrowableResultOfMethodCallIgnored") public static void assertThrows( Class throwableClass, ThrowingRunnable runnable) { - expectThrows(throwableClass, runnable); + // Delegated to AssertJ; the thrown-type check keeps TestNG semantics (subtypes accepted), only + // the failure message differs. expectThrows is kept as-is because it returns the caught + // exception, which AssertJ's fluent API does not expose. + assertThatExceptionOfType(throwableClass).isThrownBy(runnable::run); } /** * Asserts that {@code runnable} throws an exception of type {@code throwableClass} when executed. * If it does not throw an exception, an {@link AssertionError} is thrown. If it throws the wrong - * type of exception, an {@code AssertionError} is thrown describing the mismatch; the exception - * that was actually thrown can be obtained by calling {@link AssertionError#getCause}. + * type of exception, an {@code AssertionError} is thrown describing the mismatch. To obtain the + * exception that was actually thrown, use {@link #expectThrows} instead. * * @param message fail message * @param throwableClass the expected type of the exception @@ -2413,7 +2407,8 @@ public static void assertThrows( */ public static void assertThrows( String message, Class throwableClass, ThrowingRunnable runnable) { - expectThrows(message, throwableClass, runnable); + // Delegated to AssertJ (see the typed overload above). + assertThatExceptionOfType(throwableClass).as(message).isThrownBy(runnable::run); } /** diff --git a/src/main/java/org/testng/asserts/Assertion.java b/src/main/java/org/testng/asserts/Assertion.java index a3778de..bd20747 100644 --- a/src/main/java/org/testng/asserts/Assertion.java +++ b/src/main/java/org/testng/asserts/Assertion.java @@ -5,6 +5,7 @@ import java.util.Set; /** An assert class with various hooks allowing its behavior to be modified by subclasses. */ +@SuppressWarnings("deprecation") // delegates to the deprecated org.testng.Assert public class Assertion implements IAssertLifecycle { protected void doAssert(IAssert assertCommand) { onBeforeAssert(assertCommand); From c67808e8f45415ca38c7b22590cb5ca1b6bf781e Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sun, 28 Jun 2026 17:35:30 +0200 Subject: [PATCH 2/3] Delegate assertTrue/assertFalse/fail to AssertJ Follow-up to the AssertJ delegation: extend it to the remaining behaviour-identical entry points. - assertTrue/assertFalse -> assertThat(condition).as(message).isTrue()/isFalse() - fail(message) and fail(message, cause) -> org.assertj.core.api.Assertions.fail(...) These flow through the OO Assertion/SoftAssert wrappers automatically (they delegate to org.testng.Assert), so soft assertions now use AssertJ for these checks too. Only the failure-message text changes; the pass/fail behaviour is unchanged. Updated the message-format-sensitive tests accordingly: - assertTrue/assertFalse message regexps now match AssertJ's output. - SoftAssertTest#testAssertAllCount no longer counts report lines (AssertJ messages are multi-line); it asserts the failed message is reported once. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/java/org/testng/Assert.java | 20 +++++++++---------- src/test/java/org/testng/AssertTest.java | 4 ++-- .../java/test/assertion/AssertionTest.java | 4 +++- .../java/test/assertion/SoftAssertTest.java | 9 +++++---- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/testng/Assert.java b/src/main/java/org/testng/Assert.java index 86ab323..c450f11 100644 --- a/src/main/java/org/testng/Assert.java +++ b/src/main/java/org/testng/Assert.java @@ -59,9 +59,8 @@ protected Assert() { * @param message the assertion error message */ public static void assertTrue(boolean condition, String message) { - if (!condition) { - failNotEquals(condition, Boolean.TRUE, message); - } + // Delegated to AssertJ (behaviour-identical, only the failure message differs). + assertThat(condition).as(message).isTrue(); } /** @@ -81,9 +80,8 @@ public static void assertTrue(boolean condition) { * @param message the assertion error message */ public static void assertFalse(boolean condition, String message) { - if (condition) { - failNotEquals(condition, Boolean.FALSE, message); // TESTNG-81 - } + // Delegated to AssertJ (behaviour-identical, only the failure message differs). + assertThat(condition).as(message).isFalse(); } /** @@ -102,10 +100,9 @@ public static void assertFalse(boolean condition) { * @param realCause the original exception */ public static void fail(String message, Throwable realCause) { - AssertionError ae = new AssertionError(message); - ae.initCause(realCause); - - throw ae; + // Delegated to AssertJ (behaviour-identical: throws an AssertionError with the given message + // and cause). + org.assertj.core.api.Assertions.fail(message, realCause); } /** @@ -114,7 +111,8 @@ public static void fail(String message, Throwable realCause) { * @param message the assertion error message */ public static void fail(String message) { - throw new AssertionError(message); + // Delegated to AssertJ (behaviour-identical: throws an AssertionError with the given message). + org.assertj.core.api.Assertions.fail(message); } /** Fails a test with no message. */ diff --git a/src/test/java/org/testng/AssertTest.java b/src/test/java/org/testng/AssertTest.java index 0f1a60a..65f0abf 100644 --- a/src/test/java/org/testng/AssertTest.java +++ b/src/test/java/org/testng/AssertTest.java @@ -437,7 +437,7 @@ public void testAssertEqualsNoOrderNotDeep() { @Test( description = "GITHUB-2080", expectedExceptions = AssertionError.class, - expectedExceptionsMessageRegExp = "test expected \\[true\\] but found \\[false\\]") + expectedExceptionsMessageRegExp = "(?s)\\[test\\] .*Expecting value to be true but was false") public void testAssertTrueMessage() { Assert.assertTrue(false, "test"); } @@ -445,7 +445,7 @@ public void testAssertTrueMessage() { @Test( description = "GITHUB-2080", expectedExceptions = AssertionError.class, - expectedExceptionsMessageRegExp = "test expected \\[false\\] but found \\[true\\]") + expectedExceptionsMessageRegExp = "(?s)\\[test\\] .*Expecting value to be false but was true") public void testAssertFalseMessage() { Assert.assertFalse(true, "test"); } diff --git a/src/test/java/test/assertion/AssertionTest.java b/src/test/java/test/assertion/AssertionTest.java index 25825b3..c170992 100644 --- a/src/test/java/test/assertion/AssertionTest.java +++ b/src/test/java/test/assertion/AssertionTest.java @@ -36,7 +36,9 @@ public void test2() { "onAfterAssert"); } - @Test(expectedExceptions = AssertionError.class, expectedExceptionsMessageRegExp = "Raw test .*") + @Test( + expectedExceptions = AssertionError.class, + expectedExceptionsMessageRegExp = "(?s)\\[Raw test\\] .*") public void test2_fails() { try { rawAssertion.assertTrue(true); diff --git a/src/test/java/test/assertion/SoftAssertTest.java b/src/test/java/test/assertion/SoftAssertTest.java index 23a4b91..a0b1b26 100644 --- a/src/test/java/test/assertion/SoftAssertTest.java +++ b/src/test/java/test/assertion/SoftAssertTest.java @@ -63,10 +63,11 @@ public void testAssertAllCount() { sa.assertAll(); Assert.fail("Exception expected"); } catch (AssertionError e) { - String[] lines = e.getMessage().split("\r?\n"); - Assert.assertEquals(lines.length, 2); - lines[1] = lines[1].replaceFirst(message, ""); - Assert.assertFalse(lines[1].contains(message)); + // Exactly one assertion failed, so the user message is reported exactly once. (Line counting + // is avoided here because the delegated AssertJ failure messages span multiple lines.) + String report = e.getMessage(); + Assert.assertTrue(report.contains(message), report); + Assert.assertEquals(report.indexOf(message), report.lastIndexOf(message), report); } } From 4c9ecfa0ba487d7f8e8085879c17fdf2eed3fcb3 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Tue, 30 Jun 2026 09:30:27 +0200 Subject: [PATCH 3/3] Link the migration guide URL from Assert's javadoc Address review feedback (Krishnan): point the @deprecated javadoc to the hosted migration guide URL instead of the in-repo file path, so it is clickable from generated javadoc. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/java/org/testng/Assert.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/testng/Assert.java b/src/main/java/org/testng/Assert.java index c450f11..5fb55b4 100644 --- a/src/main/java/org/testng/Assert.java +++ b/src/main/java/org/testng/Assert.java @@ -38,7 +38,9 @@ * @deprecated TestNG no longer maintains its own assertion library. Use a dedicated assertion * library such as AssertJ instead. The OpenRewrite * recipe {@code org.openrewrite.java.testing.testng.TestNgToAssertj} migrates the whole class - * automatically; see {@code docs/MIGRATING_ASSERTIONS.md} for details. + * automatically; see the + * migration guide for details. */ @Deprecated public class Assert {