A lambda expression is a one-line anonymous function. Java added them in Java 8 as a way to pass behaviour — not just data — to methods. The same idea exists in JavaScript (arrow functions: x => x.toUpperCase()), Python (lambda x: x.upper()), and Kotlin. Java's version is statically typed and works with any functional interface — an interface with a single abstract method. Streams, Collection.forEach, Comparator, JUnit's assertThrows, and almost every modern Java API designed since 2014 takes lambdas. This lesson covers the syntax, the four functional interfaces you'll meet daily, and why they make test code shorter without making it harder to read.
The syntax — three flavours
Java's lambda is (parameters) -> body:
// 1) zero parameters
Runnable r = () -> System.out.println("done");
// 2) one parameter, one expression — body is the result
Function<String, Integer> length = s -> s.length();
// 3) two parameters, multi-statement body, explicit return
Comparator<String> byLength = (a, b) -> {
if (a.length() != b.length()) return Integer.compare(a.length(), b.length());
return a.compareTo(b);
};Three rules:
- Parentheses around parameters are optional for exactly one parameter.
s -> s.length()and(s) -> s.length()are the same. - Curly braces and
returnare optional for a single-expression body. The expression's value is the return. - Braces and
returnare required when the body has multiple statements.
That's the whole syntax. Everything else is what type the lambda has.
Before lambdas — anonymous classes
To appreciate why lambdas exist, look at the equivalent code without them. Sorting a list with a custom comparator pre-Java 8:
import java.util.*;
public class OldSchool {
public static void main(String[] args) {
List<String> tests = new ArrayList<>(List.of("CheckoutTest", "Login", "ProductSearchTest"));
tests.sort(new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
});
System.out.println(tests);
}
}Six lines of ceremony for one comparison. The same code with a lambda:
tests.sort((a, b) -> Integer.compare(a.length(), b.length()));One line. Same behaviour. The compiler still creates the Comparator instance under the hood — it just doesn't make you type the boilerplate. That five-line saving multiplies across every callback in your test framework.
Functional interfaces — what types lambdas can be
Lambdas implement interfaces with a single abstract method (SAM). The compiler matches the lambda's shape to the interface's one method. JUnit's Executable, Java's Comparator, JavaScript-style callbacks: every one is a single-method interface.
Java 8 ships four "general-purpose" functional interfaces in java.util.function that cover most cases:
import java.util.function.*;
Predicate<String> isEmpty = s -> s.isEmpty(); // T -> boolean
Function<String, Integer> len = s -> s.length(); // T -> R
Consumer<String> printer = s -> System.out.println(s); // T -> void
Supplier<String> uuid = () -> java.util.UUID.randomUUID().toString(); // () -> TWhat each is for:
Predicate<T>— boolean test. Used byfilter,removeIf,anyMatch. "Is this user active?"Function<T, R>— transform aTinto anR. Used bymap. "Get the user's name."Consumer<T>— do something with aT, return nothing. Used byforEach. "Print this user."Supplier<T>— produce aTfrom nothing. Used by lazy defaults, factories. "Generate a UUID."
There are bi-prefixed versions (BiFunction<T, U, R>, BiPredicate<T, U>) for two-argument variants, plus int/long/double-specialised versions for primitives (IntPredicate, ToIntFunction) when boxing matters.
A real test-data example
Filter, sort, and print a list of users — pre-streams-style — using each functional interface:
import java.util.*;
import java.util.function.*;
public class FilterUsers {
record User(String name, String role, boolean active) {}
public static void main(String[] args) {
List<User> users = List.of(
new User("Alice", "admin", true),
new User("Bob", "member", false),
new User("Carol", "admin", true),
new User("Dave", "guest", true)
);
Predicate<User> isActiveAdmin = u -> u.active() && u.role().equals("admin");
Function<User, String> toName = User::name; // method reference
Consumer<String> logIt = System.out::println;
// imperative loop with a lambda predicate
List<String> activeAdminNames = new ArrayList<>();
for (User u : users) {
if (isActiveAdmin.test(u)) {
activeAdminNames.add(toName.apply(u));
}
}
activeAdminNames.forEach(logIt);
}
}Output:
Alice
Carol
Lesson 4 will collapse the loop into a Stream pipeline. The point of this example is the types: Predicate for the filter test, Function for the projection, Consumer for the print step. Any modern Java collection method that takes "a thing to do with each element" expects one of these.
Method references — the shorter shorthand
When a lambda just calls a method, Java offers an even tighter syntax — the method reference:
Function<User, String> toName = User::name; // u -> u.name()
Consumer<String> printer = System.out::println; // s -> System.out.println(s)
Predicate<String> notEmpty = s -> !s.isEmpty(); // (no method-reference equivalent)The :: separates the receiver from the method name. There are four shapes:
ClassName::staticMethod—Integer::parseIntinstance::method—System.out::printlnClassName::instanceMethod—String::trim(the receiver becomes the first argument)ClassName::new— constructor reference:ArrayList::new
You'll see method references all over Stream pipelines (lesson 4) — users.stream().map(User::name) is the canonical shape. They're not new functionality, just a more readable way to write a lambda whose body is exactly one method call.
Lambdas in real frameworks
You'll meet lambdas everywhere in modern Java test code:
- Streams —
list.stream().filter(...).map(...).collect(...). (Next lesson.) Collection.forEach,Map.forEach—users.forEach(System.out::println). (Lesson 6.4.)- JUnit 5 assertions —
assertThrows(IllegalArgumentException.class, () -> cfg.setRetries(-1));runs the lambda and asserts it throws. - Selenium WebDriverWait —
wait.until(d -> d.findElement(By.id("ok")).isDisplayed());blocks until the lambda returns a truthy value. - Comparators —
users.sort(Comparator.comparing(User::name));chained, fluent, type-safe.
The common shape: a method that takes "a snippet of code." Before Java 8 you'd have to write an anonymous class; now you write a lambda. The verbosity of pre-Java-8 code is one of the main reasons Java got its reputation; that reputation is mostly out of date.
Anonymous class vs lambda
Anonymous class vs lambda — same behaviour, less ceremony
Anonymous class (pre-Java 8)
tests.sort(new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
});
6 lines of ceremony to express one comparison
Lambda (Java 8+)
tests.sort((a, b) -> Integer.compare(a.length(), b.length()));
1 line. Same behaviour.
Compiler still creates a Comparator under the hood — you skip the typing
Even tighter with a method reference: Comparator.comparingInt(String::length)
The compiler treats both forms the same. The lambda is just sugar — but it's sugar that turns six-line callbacks into one-line ones. For test code where callbacks are everywhere (waits, predicates, comparators, listeners), that's a real readability win.
Effectively final — the variable-capture rule
A lambda can read variables from its surrounding scope, but only if those variables are final or effectively final (never reassigned after their initial value):
String env = "staging";
Predicate<User> envMatch = u -> u.role().equals(env); // ✅ env is effectively final
env = "production"; // ❌ now lambda captures a non-effectively-final var → compile errorThe rule exists because the lambda might run later (on another thread, after the local variable's stack frame is gone). Capturing a mutable reference would be unsafe. Practically: don't reassign variables you've used in a lambda. If you really need mutable state, capture a final List<> or final int[] counter = {0} and mutate the contents.
⚠️ Common mistakes
- Confusing lambda parameters with their declared types.
(a, b) -> ...works because the compiler infers the types from the target functional interface. If you write(int a, int b) -> ...and the interface expects Strings, you get a confusing error. Let the compiler infer; only specify types when you genuinely need to disambiguate overloaded methods. - Capturing a mutable variable. Re-assigning a local variable that a lambda closed over is a compile error (variable used in lambda expression should be final or effectively final). The fix is either to not reassign, or to wrap state in a tiny container (an
int[]of length 1) — but usually the right move is to restructure to avoid the mutation. - Treating
forEachas a way to transform.users.forEach(u -> u.role.equals("admin"))does nothing useful —forEachreturnsvoid, and the lambda's return value is discarded. To transform, use a Stream's.map(...)(lesson 4);forEachis for side effects.
🎯 Practice task
Convert anonymous-class callbacks into lambdas. 25-30 minutes.
-
Create
Callbacks.java. BuildList<String> tests = new ArrayList<>(List.of("LoginTest", "CheckoutTest", "Search", "Logout"));. -
Anonymous class first. Use
tests.sort(new Comparator<String>() { ... })to sort by length, ascending. Print the list. -
Lambda. Re-sort using
tests.sort((a, b) -> Integer.compare(a.length(), b.length()));. Confirm the result is identical. -
Method reference. Replace with
tests.sort(Comparator.comparingInt(String::length));. Same result, fewer characters. -
Define each of the four general-purpose functional interfaces:
Predicate<String> startsWithLogin = s -> s.startsWith("Login");Function<String, Integer> nameLength = String::length;Consumer<String> printer = System.out::println;Supplier<String> uuid = () -> java.util.UUID.randomUUID().toString();
Call each one (
predicate.test(...),function.apply(...),consumer.accept(...),supplier.get()) and print the result. -
Use
tests.removeIf(t -> t.length() > 8);to drop long names. Confirm only short names survive. -
Stretch: define
record User(String name, String role)with a small list. Useusers.sort(Comparator.comparing(User::role).thenComparing(User::name))to sort by role first, then by name. Comparator chaining like that is the kind of thing that would be 30 lines of pre-Java-8 boilerplate; it's two lines now.
You can now express behaviour as a value. Lesson 4 brings it home — the Streams API uses these same lambdas to chain filter / map / collect into the test-data processing pipelines you'll write every day.