String Methods and StringBuilder

8 min read

Strings are the most common type in test code — URLs, selectors, response bodies, log messages, status labels. Java's String class is rich and immutable: every operation returns a new String rather than modifying the original. That immutability is why == comparisons fail (lesson 1.2) and why string-concatenation in a loop is a performance trap. This lesson covers the methods you'll use daily, the formatting helpers that replace ugly + chains, and StringBuilder — the mutable companion that fixes the loop performance problem.

Strings are immutable

Every method that "modifies" a String actually returns a new one:

String s = "hello";
s.toUpperCase();              // returns a new "HELLO" — but we threw it away
System.out.println(s);        // still "hello"
 
s = s.toUpperCase();          // capture the result
System.out.println(s);        // "HELLO"

If you don't reassign, the result is discarded. This is a frequent beginner bug — calling s.trim() on a line and wondering why the whitespace is still there. The methods are non-mutating; you must capture their return values.

The everyday methods

public class StringBasics {
    public static void main(String[] args) {
        String url = "https://staging.myapp.com/dashboard";
        String email = "  Alice@TEST.com  ";
        String csv = "Chrome,Firefox,Safari,Edge";
 
        // length — number of characters
        System.out.println("len: " + url.length());
 
        // contains / startsWith / endsWith — substring queries
        System.out.println(url.contains("dashboard"));
        System.out.println(url.startsWith("https"));
        System.out.println(url.endsWith(".com/dashboard"));
 
        // substring — slice
        System.out.println(url.substring(8));            // staging.myapp.com/dashboard
        System.out.println(url.substring(8, 25));        // staging.myapp.com
 
        // trim / strip — clean whitespace
        System.out.println("[" + email.trim() + "]");
        System.out.println("[" + email.strip() + "]");   // Java 11+, Unicode-aware
 
        // case
        System.out.println(email.toLowerCase().trim());
 
        // replace — exact substring
        System.out.println(url.replace("staging", "production"));
 
        // split — array of pieces
        String[] browsers = csv.split(",");
        System.out.println("first: " + browsers[0] + ", count: " + browsers.length);
 
        // join — collection back to string
        System.out.println(String.join(" | ", browsers));
 
        // isEmpty / isBlank
        System.out.println("\"\".isEmpty():    " + "".isEmpty());
        System.out.println("\"  \".isBlank():  " + "  ".isBlank());
    }
}

Output:

len: 36
true
true
true
staging.myapp.com/dashboard
staging.myapp.com
[Alice@TEST.com]
[Alice@TEST.com]
alice@test.com
https://production.myapp.com/dashboard
first: Chrome, count: 4
Chrome | Firefox | Safari | Edge
"".isEmpty():    true
"  ".isBlank():  true

A few callouts:

  • equals / equalsIgnoreCase — content equality. Always over == for Strings (chapter 2.1).
  • trim() vs strip()trim removes ASCII whitespace; strip (Java 11+) is Unicode-aware. Prefer strip in modern code.
  • isEmpty() vs isBlank()isEmpty is true only for ""; isBlank is true for "", " ", "\t\n". For "user typed nothing meaningful," isBlank.
  • substring(start) vs substring(start, end) — both with zero-based indices, the second is exclusive. substring(8, 25) returns characters 8 through 24.
  • split(regex) — note the argument is a regex (lesson 2). For literal splits, single characters work as expected; for . or | you need to escape (split("\\.")).

String.format — printf-style formatting

Building strings with + gets ugly:

String name = "Login";
String status = "PASSED";
long ms = 1450;
String line = name + ": " + status + " (" + ms + "ms)";

String.format does the same with placeholders:

String line = String.format("%s: %s (%dms)", name, status, ms);
System.out.println(line);

Output:

Login: PASSED (1450ms)

The conversions you'll use most:

  • %s — String (calls .toString() on whatever you pass)
  • %d — integer
  • %f / %.2f — float/double, optionally with precision
  • %n — platform newline (use this, not \n, in formatted output)
  • %-20s — left-aligned, padded to 20 columns

System.out.printf(...) writes the formatted string straight to stdout without the intermediate variable.

A real example — a fixed-width test report row:

System.out.printf("%-20s | %-10s | %6dms%n", "checkout-suite", "FAILED", 3120);
System.out.printf("%-20s | %-10s | %6dms%n", "login",          "PASSED", 1450);

Output:

checkout-suite       | FAILED     |   3120ms
login                | PASSED     |   1450ms

The columns line up because every cell is padded to a fixed width. The same trick is what makes JUnit and TestNG console output readable.

StringBuilder — concatenation in loops

Strings are immutable. Each + allocates a new String and copies the characters:

String report = "";
for (TestResult r : results) {
    report += r.name + ": " + r.status + "\n";    // ❌ allocates O(n²) bytes total
}

Three iterations are fine. A thousand iterations is measurably slow. StringBuilder is a mutable buffer that appends in place; you call .toString() once at the end:

StringBuilder report = new StringBuilder();
for (TestResult r : results) {
    report.append(r.name)
          .append(": ")
          .append(r.status)
          .append("\n");
}
String finalReport = report.toString();

append returns the same StringBuilder, so you can chain calls. The whole loop is O(n) instead of O(n²). Use StringBuilder whenever you build a string in a loop.

A working example:

public class ReportBuilder {
 
    static class TestResult {
        String name; String status; long durationMs;
        TestResult(String n, String s, long d) { name = n; status = s; durationMs = d; }
    }
 
    public static void main(String[] args) {
        TestResult[] results = {
            new TestResult("Login",    "PASSED", 1450),
            new TestResult("Search",   "PASSED", 820),
            new TestResult("Checkout", "FAILED", 3120),
            new TestResult("Logout",   "PASSED", 990)
        };
 
        StringBuilder sb = new StringBuilder();
        sb.append("=== Run Summary ===").append(System.lineSeparator());
 
        for (TestResult r : results) {
            sb.append(String.format("%-12s %-8s %6dms%n", r.name, r.status, r.durationMs));
        }
 
        sb.append("===================");
        System.out.println(sb.toString());
    }
}

Output:

=== Run Summary ===
Login        PASSED     1450ms
Search       PASSED      820ms
Checkout     FAILED     3120ms
Logout       PASSED      990ms
===================

StringBuilder here pulls double duty — efficient concatenation and a convenient place to compose a multi-line report before printing it.

Cleaning API response strings — a real QA case

Whitespace, casing, and stray characters in API or DOM-extracted text catch tests that look right at a glance:

public class CompareSafely {
    public static void main(String[] args) {
        String fromApi = "  Alice@TEST.com  \n";   // copied from a JSON field
        String expected = "alice@test.com";
 
        boolean naive  = fromApi.equals(expected);                  // false
        boolean clean  = fromApi.strip().toLowerCase().equals(expected);
 
        System.out.println("naive: " + naive);
        System.out.println("clean: " + clean);
    }
}

Output:

naive: false
clean: true

Reaching for strip().toLowerCase() (or equalsIgnoreCase) before comparing values pulled from external sources is a habit worth building early. It's the difference between flaky tests that pass on Mondays and tests that mean something.

A field guide to String methods

The four buckets cover almost everything you'll do to a String in test code. Memorise the bucket — when you need a method, you can usually guess the name; if you can't, your IDE's autocomplete will surface it.

⚠️ Common mistakes

  • Calling a String method and ignoring the return value. s.trim(); does nothing useful — the cleaned String is thrown away. Reassign or chain: s = s.trim(); or if (s.trim().equals("OK")) .... Strings are immutable; the methods can't change s in place.
  • s == "expected" for content comparison. Reviewed twice already because it's the most common Java bug. .equals() (or .equalsIgnoreCase()) for content. == for primitives only.
  • Building large strings with += in a loop. O(n²) work; the JVM cannot optimise this away. Use StringBuilder.append(...) and call .toString() once at the end. The single-line + outside loops is fine — the compiler optimises individual concatenations into one StringBuilder call.

🎯 Practice task

Build a formatted test report. 25-30 minutes.

  1. Create RunReport.java. Define a small static class Result { String name; String status; long durationMs; } with a constructor.
  2. In main, build Result[] results = { ... } with at least five mixed pass/fail results.
  3. Use a StringBuilder to compose the full report. Add a header line === Run ===, then one line per result formatted with String.format("%-15s %-8s %6dms%n", ...). Use System.lineSeparator() for the header newline.
  4. Compute three summary numbers from the array: total, passed (use a counter), and average duration. Append them to the StringBuilder as a footer.
  5. Print the full report once with System.out.println(sb.toString()).
  6. Add Result entries with leading/trailing whitespace in name and uppercase status (e.g. " Login ", "PaSseD"). Inside the loop, strip() the name and toUpperCase() the status before formatting. Confirm the report is consistent.
  7. Stretch: convert the loop to use String.join(System.lineSeparator(), lines) where lines is a List<String> you build inside the loop. Compare readability. For a small number of lines, String.join is often clearer than a StringBuilder.

You can now compose any string a test framework needs. Lesson 2 introduces regular expressions — the next layer up, when "starts with" and "contains" aren't enough.

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