Multi-Dimensional Arrays

7 min read

A 2D array is an array of arrays — Java's natural shape for tabular data. Each outer element is a row; each row is itself an array of cells. You'll see this shape everywhere in QA: TestNG @DataProvider methods return Object[][], parameterised JUnit tests accept String[][], browser/OS/resolution matrices are 2D string arrays. Real test frameworks gradually move from raw 2D arrays to objects (chapter 4) for richer data, but the array form remains the lingua franca for parameterised test inputs.

Declaring a 2D array

The literal form makes the row/column shape visible:

String[][] testMatrix = {
    {"Chrome",  "Windows", "1920x1080"},
    {"Firefox", "macOS",   "1366x768"},
    {"Safari",  "macOS",   "375x667"},
    {"Edge",    "Windows", "1440x900"}
};

Read String[][] as "an array of String arrays." Each inner {...} is one row.

The sized form allocates an empty grid:

int[][] grid = new int[3][4];     // 3 rows × 4 columns, all zeros
boolean[][] flags = new boolean[5][2];   // 5×2 booleans, all false

new int[3][4] makes 3 row arrays, each of length 4 — twelve cells in total, all defaulting to 0.

Indexing — [row][column]

Two square brackets, outer first (the row), inner second (the column):

String[][] testMatrix = {
    {"Chrome",  "Windows", "1920x1080"},
    {"Firefox", "macOS",   "1366x768"},
    {"Safari",  "macOS",   "375x667"}
};
 
System.out.println(testMatrix[0][0]);   // Chrome
System.out.println(testMatrix[0][1]);   // Windows
System.out.println(testMatrix[2][2]);   // 375x667

Output:

Chrome
Windows
375x667

Both indices are zero-based. testMatrix.length is the number of rows (3); testMatrix[0].length is the number of columns in row 0 (3). The latter is per-row because — as we'll see in a moment — rows can be different sizes if you let them.

Iterating — nested for loops

Two ways to walk the whole grid. The indexed form when you need the row/column number:

for (int r = 0; r < testMatrix.length; r++) {
    for (int c = 0; c < testMatrix[r].length; c++) {
        System.out.println("[" + r + "][" + c + "] = " + testMatrix[r][c]);
    }
}

The enhanced for-each form when you just need the values:

for (String[] row : testMatrix) {
    System.out.println(row[0] + " on " + row[1] + " at " + row[2]);
}

Output of the second loop:

Chrome on Windows at 1920x1080
Firefox on macOS at 1366x768
Safari on macOS at 375x667

The outer loop hands you each row (which is itself a String[]), and you index into it normally. This is the shape @DataProvider methods iterate in TestNG.

Jagged arrays — rows can differ in length

Java's 2D arrays are not strictly rectangular. Each row is its own independent array, so different rows can have different lengths:

String[][] testCases = {
    {"login",     "alice@x.com", "valid_password"},
    {"checkout",  "10 items"},
    {"search",    "java", "books", "free shipping"}
};
 
for (int r = 0; r < testCases.length; r++) {
    System.out.println("Row " + r + " has " + testCases[r].length + " column(s)");
}

Output:

Row 0 has 3 column(s)
Row 1 has 2 column(s)
Row 2 has 4 column(s)

This jagged shape is occasionally useful — when each test case has a different number of inputs. More often it's a footgun: you write row[2] expecting a value and crash on a row that only has two columns. If your data should be rectangular, treat differing row lengths as a bug and validate the input.

A test matrix with a runner

Putting everything together — a parameterised cross-browser test matrix:

public class CrossBrowserMatrix {
 
    public static void runTest(String browser, String os, String resolution) {
        System.out.println("→ Testing on " + browser + " / " + os + " @ " + resolution);
    }
 
    public static void main(String[] args) {
        String[][] matrix = {
            {"Chrome",  "Windows", "1920x1080"},
            {"Chrome",  "macOS",   "1440x900"},
            {"Firefox", "macOS",   "1366x768"},
            {"Safari",  "macOS",   "375x667"},
            {"Edge",    "Windows", "1920x1080"}
        };
 
        System.out.println("Running " + matrix.length + " configurations:");
        for (String[] config : matrix) {
            runTest(config[0], config[1], config[2]);
        }
    }
}

Output:

Running 5 configurations:
→ Testing on Chrome / Windows @ 1920x1080
→ Testing on Chrome / macOS @ 1440x900
→ Testing on Firefox / macOS @ 1366x768
→ Testing on Safari / macOS @ 375x667
→ Testing on Edge / Windows @ 1920x1080

Each row of the matrix is one test configuration; the loop calls runTest with the three columns as parameters. This is exactly what @DataProvider methods do in TestNG — the shape they return is Object[][], and the test framework calls your test method once per row.

Printing a 2D array — Arrays.deepToString

Arrays.toString prints one level. For 2D arrays, you need Arrays.deepToString:

import java.util.Arrays;
 
System.out.println(Arrays.toString(matrix));
// [[Ljava.lang.String;@1b6d3586, [Ljava.lang.String;@4554617c, ...]   ❌
 
System.out.println(Arrays.deepToString(matrix));
// [[Chrome, Windows, 1920x1080], [Chrome, macOS, 1440x900], ...]      ✅

Arrays.toString walks one level — fine for String[], useless for String[][] because each row is itself an array. Arrays.deepToString recursively walks every level. Use it any time you need to debug a multi-dimensional structure.

A grid, visualised

testMatrix[1][2] is "1366x768" — row index 1, column index 2. The grid mental model is what makes 2D arrays feel natural once you've used them a few times.

When NOT to use a 2D array

A 2D array of String works for simple tabular data, but it gets clumsy quickly:

  • Each column is an unlabelled position. Anyone reading config[2] has to remember "column 2 is resolution." A typo in column index is a silent bug — not even a compile error, because config[2] is just a String like the others.
  • All cells must be the same type. Mix a boolean isMobile into the row and you can't keep String[][] — you'd need Object[][], which loses the type safety.
  • Adding a column means changing every row literal. Painful with 50 rows.

The real-world fix is to define a class — BrowserConfig { String browser; String os; String resolution; } — and use a BrowserConfig[] or List<BrowserConfig>. Each field has a name; each field has its own type; adding a new field is one line in the class. You'll learn this shape in chapters 4 and 6.

In the meantime, 2D arrays are perfect for small tabular data — exactly the shape TestNG and JUnit's parameterised tests already use. Read 2D arrays fluently; reach for objects when the table grows up.

⚠️ Common mistakes

  • Confusing matrix.length with matrix[0].length. The first is the number of rows; the second is the number of columns in row 0. They're equal only for rectangular grids; jagged arrays make them differ.
  • Forgetting Arrays.deepToString for 2D arrays. Plain Arrays.toString(matrix) prints the inner String[] references, not their contents. You'll see lines like [Ljava.lang.String;@... and wonder if the test framework is broken — it's just the wrong helper. Use deepToString.
  • Indexing [col][row] instead of [row][col]. Pure muscle-memory bug. The convention is outer = row, inner = column. If your loops produce a transposed-looking result, you probably swapped them.

🎯 Practice task

Build a small parameterised test runner. 25-30 minutes.

  1. Create LoginMatrix.java and import java.util.Arrays;.
  2. Declare a String[][] testCases with at least four rows. Each row should hold three columns: email, password, expectedResult ("PASS" or "FAIL"). Mix valid and invalid combinations.
  3. Print the count of test cases (testCases.length).
  4. Loop with a for-each over testCases. For each row, print a line like → alice@x.com / hunter2 → expected PASS.
  5. Add a method static String runLogin(String email, String password) that returns "PASS" if the email contains @ and the password length ≥ 6, otherwise "FAIL". (Pretend that's the rule the real app enforces.)
  6. Inside the loop, call runLogin(...) and compare its result to the row's expectedResult with .equals(). Print match or MISMATCH per row.
  7. Print the whole table with Arrays.deepToString(testCases) to confirm deepToString works as advertised.
  8. Stretch: add a fourth column priority (e.g. "P1", "P2") to every row. Run the program and notice that the column index logic in step 4 (row[2] for expected, row[3] for priority) is now position-coupled — easy to swap by mistake. That fragility is the precise reason chapter 4 introduces classes; reading 2D-array code with the friction it creates is the fastest way to appreciate why objects exist.

You now have arrays — one-dimensional and two-dimensional — fully under your control. Chapter 4 starts the real journey: classes, objects, and the OOP foundations every Selenium and TestNG framework is built on.

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