Learn java, with practice after every lesson
10 lessons, about 170 minutes of reading, and 30 multiple-choice questions. Each lesson names the mistake that most often costs people the interview, because that is where the hours actually go. Part of the free coding course.
Types, classes and main
BasicsJava requires you to declare the type of everything, and all code lives inside a class. That feels heavy at first, but it is exactly why large teams and banks use it: the compiler catches whole categories of mistake before the program ever runs, which matters more when fifty people share a codebase than when one person writes a script.
Every Java program starts at a main method. Enterprise employers — especially in banking, insurance and large-scale backend work — still hire heavily for Java, so it remains a strong choice despite the extra ceremony, and Indian services companies in particular recruit for it in volume at entry level.
The mistake that defines Java interviews is comparing strings with ==. That compares references — whether two variables point at the same object in memory — not contents. Two strings with identical characters can be different objects, so == returns false while .equals() returns true. It is asked constantly because it reveals whether you understand that objects are references.
Syntax
public class Payroll {
public static void main(String[] args) {
String name = "Ravi";
int years = 6;
double salary = 82000.50;
boolean active = true;
System.out.println(name + " — " + years + " yrs");
System.out.printf("Salary: %.2f%n", salary);
}
}
Key points
- The file name must match the public class name exactly — Payroll.java for class Payroll.
- int holds whole numbers, double holds decimals; dividing two ints truncates the result.
- String is a class (capital S), unlike the primitives int, double and boolean.
Practice challenge
Evaluate and print boolean result.
true
Show a hint
- Use > for comparison
- 85 > 80 evaluates to true
- Prints "true" or "false"
Check yourself
1. How do you compare two strings' contents in Java?
Show answer
B. .equals()
2. What does 7 / 2 give when both are int?
Show answer
B. 3
3. What must match the public class name?
Show answer
B. The file name
Collections and loops
Working levelArrays in Java are fixed in size, which is rarely what you want. ArrayList grows as you add to it, and HashMap stores key–value pairs — between them they cover most everyday Java code, and knowing when to reach for each is most of practical collections work.
The convention interviewers expect is declaring against the interface and instantiating the implementation: List<String> names = new ArrayList<>(). It means the rest of your code depends on what the collection does rather than which one it is, so swapping the implementation later changes one line instead of many.
The runtime error worth knowing before you meet it is ConcurrentModificationException, thrown when you remove from a collection while looping over it. The iterator notices the collection changed underneath it and refuses to continue. The fix is to collect what you want to remove during the loop and remove it afterwards, or use removeIf, which handles the bookkeeping for you.
Syntax
import java.util.*;
List<String> names = new ArrayList<>();
names.add("Priya");
names.add("Ravi");
for (String n : names) {
System.out.println(n);
}
Map<String, Integer> years = new HashMap<>();
years.put("Priya", 6);
System.out.println(years.getOrDefault("Amit", 0)); // 0
Key points
- Declare against the interface (List, Map) and instantiate the implementation (ArrayList, HashMap).
- getOrDefault avoids a null when the key is absent — the Java equivalent of Python's .get().
- Generics <String> let the compiler reject the wrong type before the program runs.
Practice challenge
Create and access 2D array.
92
Show a hint
- 2D array: int[][] name
- Access: array[row][col]
- [0][0] is top-left element
Check yourself
1. Which grows as you add items?
Show answer
B. ArrayList
2. What does years.getOrDefault("Amit", 0) return when absent?
Show answer
B. 0
3. What happens if you remove from a list while looping it?
Show answer
B. ConcurrentModificationException
Conditions, loops and methods
BasicsJava's if, for and while work as they do elsewhere, but everything must live inside a method, and every method declares what type it returns — void when it returns nothing. That extra structure is the point: the compiler refuses to build code whose types do not line up, so a large class of mistakes never reaches production.
The enhanced for loop, for (int s : scores), avoids the index arithmetic that causes off-by-one errors. Reach for the indexed form only when you genuinely need the position, which is less often than beginners assume.
The rule that catches people writing their first methods is that every path must return. If an if returns a value but the else path falls through with nothing, the code will not compile — Java will not let you write a method that sometimes returns nothing when it promised a String. That strictness feels obstructive for a week and then quietly prevents a category of bug you will never have to debug.
Syntax
public class Grades {
static String grade(int score) {
if (score >= 80) return "A";
else if (score >= 60) return "B";
return "C";
}
public static void main(String[] args) {
int[] scores = {82, 45, 71};
for (int s : scores) {
System.out.println(s + " -> " + grade(s));
}
}
}
Key points
- A method signature declares its return type: String grade(int score).
- void means the method returns nothing — main is void.
- The enhanced for (int s : scores) avoids index mistakes entirely.
Practice challenge
Print "Welcome to Java!" using System.out.println()
Welcome to Java!
Show a hint
- Use System.out.println()
- Put message in quotes
- Java requires exact structure shown
Check yourself
1. What does void mean?
Show answer
A. Returns nothing
2. Which loop avoids index errors?
Show answer
B. for (int s : scores)
3. What happens if a non-void method has a path with no return?
Show answer
B. It will not compile
Objects, null and exceptions
AdvancedJava is built around objects: a class defines the shape, and each instance holds its own values. Encapsulation — keeping fields private and exposing methods — is the convention interviewers expect you to follow, because it means the object controls its own state rather than trusting every caller to modify it correctly.
The failure that dominates real Java is NullPointerException. A reference that points at nothing throws the moment you use it, and defending against that is a daily habit rather than an edge case. Checking for null, returning empty collections rather than null, and using Optional where a value genuinely may be absent are all part of the same discipline.
The strongest habit is validating in the constructor. If an object cannot be created in an invalid state, every method afterwards can assume its own data is sound, and you stop writing defensive checks throughout the class. Throwing IllegalArgumentException for a blank owner name is not pedantry — it means the invalid object never exists to cause a confusing failure three layers away.
Syntax
public class Account {
private final String owner;
private double balance;
public Account(String owner, double opening) {
if (owner == null || owner.isBlank())
throw new IllegalArgumentException("owner required");
this.owner = owner;
this.balance = opening;
}
public void withdraw(double amt) {
if (amt > balance) throw new IllegalStateException("Insufficient funds");
balance -= amt;
}
public double getBalance() { return balance; }
}
Key points
- private fields with public methods is encapsulation — the caller cannot corrupt internal state.
- final means the reference cannot be reassigned after construction.
- Validate in the constructor so an invalid object can never exist in the first place.
Practice challenge
Define constructor to initialize object.
Priya
Show a hint
- Constructor name matches class name
- Called with new keyword
- Initializes object state
Check yourself
1. What does private on a field achieve?
Show answer
B. Encapsulation — outside code cannot touch it directly
2. What is the most common Java runtime failure?
Show answer
B. NullPointerException
3. Why validate inside the constructor?
Show answer
B. So an invalid object can never exist
Strings, immutability and the loop that melts
Working levelA Java String cannot be changed after it is created. Every operation that looks like modification — concat, replace, trim, toUpperCase — leaves the original untouched and hands back a new object. Beginners write s.trim(); on its own line, see nothing happen, and conclude the method is broken; it worked perfectly and they discarded the result. Assign it back, or the call was a no-op.
Immutability is a deliberate design decision, not an oversight. It makes strings safe to share between threads, safe to use as map keys, and lets the JVM pool identical literals so the same text is stored once. The cost surfaces in loops: building a string by repeated concatenation allocates a fresh object and copies everything on every iteration, so ten thousand appends do work proportional to the square of the length. StringBuilder exists precisely for this, mutating one buffer instead, and the difference on a large loop is seconds against milliseconds.
The other trap is comparison. == on two Strings asks whether they are the same object in memory, not whether they contain the same characters. It appears to work for literals, because the compiler pools them and both names point at one pooled object — and then fails the moment a string arrives from user input, a file or a database, which is to say in production and not in your test. equals() compares content and is what you almost always want; equalsIgnoreCase() for case-insensitive checks, and Objects.equals() when either side might be null.
Syntax
String s = " Priya Sharma ";
s.trim(); // result DISCARDED -- s is unchanged
s = s.trim(); // correct
// == compares IDENTITY, equals() compares CONTENT
String a = "hello";
String b = "hello";
System.out.println(a == b); // true -- both point at the pooled literal
String c = new String("hello");
System.out.println(a == c); // false -- different object, same text
System.out.println(a.equals(c)); // true <- what you meant
Scanner in = new Scanner(System.in);
String typed = in.nextLine(); // "hello", but NOT pooled
if (typed == "hello") { } // false, always. The classic bug.
if (typed.equals("hello")) { } // correct
// null-safe either way round
Objects.equals(typed, "hello");
// BUILDING STRINGS IN A LOOP
String out = "";
for (int i = 0; i < 10000; i++) out += i + ","; // new object EVERY time
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) sb.append(i).append(',');
String result = sb.toString(); // one buffer, one result
String.format("%s owes %.2f", name, amount);
String.join(", ", List.of("a", "b", "c")); // a, b, c
Key points
- Strings are immutable, so every method returns a new one. A call whose result you do not assign has done nothing.
- == compares object identity. It works by accident on pooled literals and fails on any string that came from input, a file or a database. Use equals().
- Concatenating in a loop reallocates and copies each time. StringBuilder mutates one buffer and turns a quadratic loop into a linear one.
Practice challenge
Loop through array and calculate sum.
432
Show a hint
- Use marks.length for array size
- Access array elements with marks[i]
- Add to sum in each iteration
Check yourself
1. What does s.trim(); on its own line do?
Show answer
B. Nothing to s — the returned new string was discarded
2. Why does == sometimes appear to work on strings?
Show answer
B. Identical literals are pooled, so both names reference one object
3. Why is += in a 10,000-iteration loop slow?
Show answer
B. Each concatenation allocates a new string and copies everything, so cost grows quadratically
equals, hashCode and the object that vanishes
Working levelEvery Java object inherits equals() and hashCode() from Object, and the inherited versions are almost never what you want: equals() compares memory identity, and hashCode() is derived from it. So two Customer objects with identical id, name and email are unequal by default. That is why a list containing a customer reports it is not there, and why removing one silently does nothing — contains() and remove() both call equals().
When you override equals() you must override hashCode() too, and the rule that binds them is one-directional: equal objects must produce equal hash codes, though equal hash codes do not imply equality. Break it and hash-based collections come apart. A HashMap uses hashCode() to pick a bucket and equals() only within that bucket, so two objects that are equal with different hashes land in different buckets — the map holds your entry, get() returns null, and iterating the map shows it plainly sitting there. It looks like a JVM bug and is entirely ordinary.
Two further rules matter in practice. The fields you use must be the ones that define identity — usually the id, not every field, and never a mutable field you will change later, because mutating a key after insertion changes its hash and strands the entry in the wrong bucket. And modern Java gives you the whole thing free: a record generates a correct equals(), hashCode() and toString() from its components, which is why records are the right default for data carriers and why hand-writing these methods should be rare.
Syntax
class Customer {
final long id; final String name;
Customer(long id, String name) { this.id = id; this.name = name; }
}
Customer a = new Customer(7, "Priya");
Customer b = new Customer(7, "Priya");
a.equals(b); // false -- inherited equals compares IDENTITY
List.of(a).contains(b); // false
// Override BOTH, always together
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer)) return false;
return id == ((Customer) o).id; // id defines identity
}
@Override public int hashCode() { return Long.hashCode(id); }
// BREAK THE CONTRACT AND THE MAP LOSES THINGS
// equals() overridden, hashCode() NOT:
Map<Customer, String> m = new HashMap<>();
m.put(a, "gold");
m.get(b); // null -- b hashes to a different bucket
m.size(); // 1 -- the entry is right there
m.containsKey(b);// false
// Looks like a JVM bug. It is the contract being broken.
// MUTATING A KEY AFTER INSERTION STRANDS IT
// (which is why identity fields should be final)
// Modern Java: a record generates all three, correctly
record CustomerRec(long id, String name) { }
new CustomerRec(7, "Priya").equals(new CustomerRec(7, "Priya")); // true
Key points
- Override equals() and hashCode() together or not at all. Equal objects must have equal hash codes; the reverse is not required.
- A HashMap picks the bucket by hashCode() and compares by equals() only inside it, so a broken contract makes get() return null for a key the map visibly contains.
- Base identity on stable fields, ideally final. Mutating a field used in hashCode after insertion strands the entry in a bucket nothing will look in.
Practice challenge
Create ArrayList and add/retrieve elements.
Raj
Show a hint
- ArrayList is like dynamic array
- add() appends element
- get(index) retrieves element
Check yourself
1. You override equals() but not hashCode(). What happens with HashMap?
Show answer
B. get() can return null for a key the map contains, because the bucket is chosen by hash
2. Which statement is required by the contract?
Show answer
B. Equal objects must have equal hash codes
3. What does a record give you?
Show answer
B. A generated equals(), hashCode() and toString() from its components
Interfaces, and composition over inheritance
AdvancedAn interface is a contract: it names what an object can do without saying how. Code written against the interface works with any implementation, which is what makes a system testable — a service depending on a PaymentGateway interface can be handed a real one in production and a fake in a test, with no change to the service. That single property is the reason interfaces dominate professional Java, and it is worth stating in an interview because it explains the design rather than describing it.
Inheritance also lets you share behaviour, and it is over-used. Extending a class binds you to its entire implementation forever, in a single-inheritance language where you get one parent and no second chance. The classic demonstration is a Square extending a Rectangle: mathematically sound, and broken in code, because anything setting width and height independently on what it believes is a Rectangle gets nonsense from a Square. The test is whether the subclass genuinely is substitutable for its parent everywhere the parent is used — not merely whether it is one in English.
Composition asks instead what an object has, and it is the better default. A Car holding an Engine can swap it, mock it, or hold two; a Car extending Engine can do none of that. The practical rule that follows: extend a class only when you are genuinely specialising its behaviour and can honour its contract completely; otherwise hold an instance and expose what you need. Since Java 8 interfaces may carry default methods, which lets a contract grow without breaking every implementation — useful, and not a licence to put real logic there.
Syntax
// The contract: what, not how
interface PaymentGateway {
Receipt charge(long amountPaise, String token);
default boolean supportsRefund() { return true; } // Java 8+: grow safely
}
// Production and test implementations are interchangeable
class RazorpayGateway implements PaymentGateway { /* real HTTP */ }
class FakeGateway implements PaymentGateway { // no network in tests
Receipt charge(long a, String t) { return new Receipt("test", a); }
}
class CheckoutService {
private final PaymentGateway gateway; // depends on the CONTRACT
CheckoutService(PaymentGateway gateway) { this.gateway = gateway; }
}
new CheckoutService(new FakeGateway()); // testable without a network
// INHERITANCE THAT LOOKS RIGHT AND IS WRONG
class Rectangle { void setWidth(int w){} void setHeight(int h){} }
class Square extends Rectangle { /* must keep sides equal */ }
void resize(Rectangle r) { r.setWidth(5); r.setHeight(4); }
// area 20 for a Rectangle; a Square cannot honour both. Compiles, wrong.
// The test is substitutability, not whether "a square is a rectangle".
// COMPOSITION: has-a, not is-a
class Car {
private final Engine engine; // swappable, mockable, replaceable
Car(Engine engine) { this.engine = engine; }
void start() { engine.ignite(); }
}
// Extend only to specialise behaviour you can fully honour. Otherwise hold one.
Key points
- Depending on an interface rather than a class is what makes code testable: the same service takes a real implementation in production and a fake in a test.
- Inheritance is correct only when the subclass is substitutable for the parent everywhere. 'Is-a' in English is not the test; honouring the contract is.
- Composition — holding an instance — keeps the option to swap, mock or hold several. Java gives you one parent, so spend it carefully.
Practice challenge
Create HashMap and retrieve values.
92
Show a hint
- HashMap stores key-value pairs
- put(key, value) adds entry
- get(key) retrieves value
Check yourself
1. Why does depending on an interface make code testable?
Show answer
B. Any implementation satisfies it, so a fake can replace the real one with no change to the caller
2. What is the real test for using inheritance?
Show answer
B. Whether the subclass is substitutable for the parent everywhere the parent is used
3. What does composition preserve that inheritance does not?
Show answer
B. The ability to swap, mock or hold several of the collaborator
Streams, lambdas and Optional
AdvancedA stream expresses what you want done to a collection rather than how to iterate it. Filter, map and collect replace the loop-with-an-accumulator that dominates older Java, and the gain is readability more than speed: the shape of the transformation is visible in one expression instead of reconstructed from a loop body. Streams are also lazy — nothing runs until a terminal operation such as collect, forEach or count asks for a result, which is why a pipeline with no terminal operation executes nothing at all and looks like dead code.
Two properties catch people out. A stream is single-use: consume it and any further operation throws IllegalStateException, so a stream stored in a variable and used twice fails at runtime rather than compile time. And streams do not mutate the source; they produce a new result, so a pipeline whose value is discarded has achieved nothing, exactly like an unassigned string operation. The collectors worth knowing early are toList, joining, groupingBy and counting, because they cover most of what a loop was doing.
Optional addresses the other half: methods that may have no answer. Returning Optional<Customer> instead of a nullable Customer moves the absent case into the type, so a caller cannot forget it — the compiler makes them handle it. Used properly that means orElse, orElseGet, map or ifPresent; used improperly it means calling isPresent() then get(), which is the null check written more verbosely and gains nothing. And Optional is for return types: as a field or a parameter it adds ceremony without adding safety.
Syntax
record Order(String customer, String region, long amount, String status) {}
// WHAT, not how
List<String> bigCustomers = orders.stream()
.filter(o -> o.amount() > 100_000)
.map(Order::customer)
.distinct()
.sorted()
.toList();
// grouping replaces the loop-with-a-map
Map<String, Long> revenueByRegion = orders.stream()
.collect(Collectors.groupingBy(Order::region,
Collectors.summingLong(Order::amount)));
Map<String, Long> countByStatus = orders.stream()
.collect(Collectors.groupingBy(Order::status, Collectors.counting()));
String names = orders.stream().map(Order::customer)
.collect(Collectors.joining(", "));
// LAZY: no terminal operation, nothing runs
orders.stream().filter(o -> o.amount() > 0); // does nothing at all
// SINGLE-USE: consuming twice throws at RUNTIME
var s = orders.stream();
s.count();
s.count(); // IllegalStateException
// OPTIONAL: absence in the type system
Optional<Order> findById(String id) { ... }
String who = findById("A-1").map(Order::customer).orElse("unknown");
findById("A-1").ifPresent(o -> log(o.customer()));
// pointless -- this is a null check with extra words
if (findById("A-1").isPresent()) { var o = findById("A-1").get(); }
// Optional is for RETURN types. As a field or parameter it is ceremony.
Key points
- Streams are lazy: without a terminal operation such as collect, count or forEach, nothing executes and the pipeline is dead code.
- A stream is single-use and does not mutate its source. Reusing one throws at runtime, and discarding the result achieves nothing.
- Optional belongs on return types, where it forces the caller to handle absence. isPresent() followed by get() is a null check written longer.
Practice challenge
Create method to find max value in array.
95
Show a hint
- Start max with first element
- Compare each with current max
- Update max when larger found
Check yourself
1. A stream pipeline with filter and map but no collect. What runs?
Show answer
B. Nothing — streams are lazy until a terminal operation
2. You store a stream and call count() twice. What happens?
Show answer
B. The second throws IllegalStateException — a stream is single-use
3. Where does Optional belong?
Show answer
B. On return types, so callers must handle absence
Files, resources and the leak you cannot see
AdvancedAnything you open — a file, a socket, a database connection — must be closed, and the operating system gives each process a limited number of handles. A program that leaks them runs correctly for hours and then fails with 'too many open files' under load, at which point the stack trace points at whatever unlucky call needed the next handle rather than at the code that leaked. That distance between cause and symptom is what makes resource leaks disproportionately expensive to debug.
The old solution was a finally block, and it was easy to get wrong: close() itself can throw, nesting two resources doubles the ceremony, and an exception in close can mask the original exception so you see the wrong error. try-with-resources replaces all of it. Any object implementing AutoCloseable declared in the try header is closed automatically, in reverse order, whether the block completes or throws — and if both the body and close throw, the close exception is attached as suppressed rather than replacing the real one.
For the reading itself, prefer the java.nio.file API over the older File class: Files.readAllLines for a small file, Files.lines for a large one you want to stream lazily, Files.readString when you want the whole thing as text. The distinction matters at scale, because readAllLines on a two-gigabyte file loads two gigabytes into heap while Files.lines processes it a line at a time — but note that Files.lines returns a stream backed by an open file, so it too must sit in a try-with-resources or you have leaked the handle in the very act of being careful about memory.
Syntax
// try-with-resources: closed automatically, in reverse order,
// on success or on exception
try (BufferedReader r = Files.newBufferedReader(Path.of("orders.csv"));
BufferedWriter w = Files.newBufferedWriter(Path.of("clean.csv"))) {
String line;
while ((line = r.readLine()) != null) {
if (!line.isBlank()) w.write(line + System.lineSeparator());
}
} // both closed here, w first
// small file, whole thing in memory
List<String> lines = Files.readAllLines(Path.of("small.csv"));
String text = Files.readString(Path.of("note.txt"));
// LARGE file: stream lazily -- but the stream holds the file open,
// so it needs try-with-resources too
try (Stream<String> lines2 = Files.lines(Path.of("huge.csv"))) {
long errors = lines2.filter(l -> l.contains("ERROR")).count();
}
// THE LEAK: no close at all
BufferedReader r = Files.newBufferedReader(path);
r.readLine();
// handle never released. Works fine... until "too many open files",
// thrown by whatever unlucky code needed the NEXT handle.
// Files.exists then read is a race; just handle the exception
try {
return Files.readString(path);
} catch (NoSuchFileException e) {
return "";
}
// Anything you write can implement AutoCloseable and join the pattern
class Report implements AutoCloseable {
@Override public void close() { /* release */ }
}
Key points
- try-with-resources closes every declared AutoCloseable in reverse order, on success or on exception, and keeps the original exception with close's attached as suppressed.
- Files.lines streams a large file lazily instead of loading it, but the stream holds the file open — it needs try-with-resources as much as a reader does.
- A leaked handle fails far from its cause. The exception surfaces in whatever code needed the next handle, which is why the stack trace points at the wrong place.
Practice challenge
Define generic method with type parameter.
1 2 3
Show a hint
- <T> defines type parameter
- T is placeholder for any type
- Called with different types each time
Check yourself
1. What does try-with-resources guarantee?
Show answer
B. Every declared AutoCloseable is closed, in reverse order, on success or failure
2. Why must Files.lines sit in a try-with-resources?
Show answer
B. The returned stream holds the file open and must be closed
3. Where does a 'too many open files' error usually surface?
Show answer
B. At whatever unrelated call needed the next handle
Tests, builds and shipping something real
Job-readyThe gap between people who can write Java and people who get hired is usually the surrounding machinery, and it is smaller than it looks. A build tool — Maven or Gradle — declares your dependencies and your Java version in one file, downloads what it needs, compiles, runs tests and produces a jar. Its real function is reproducibility: anyone who clones the project runs one command and gets an identical build, which is why 'it works on my machine' stops being a sentence people say.
JUnit is the test framework and the shape is always the same: arrange the inputs, act by calling the thing, assert the outcome. What separates a useful suite from a decorative one is which cases you write. The happy path is the least valuable test because it is the one you already ran by hand. The value is in the empty input, the null, the boundary, the duplicate and the error path — and in one regression test for every bug you fix, which is what stops the same bug returning in six months.
Two habits carry disproportionate weight in an interview. Name tests for the behaviour they pin, so a failure reads as a sentence about what broke rather than 'test3 failed'. And use assertThrows for the failure cases, because a method that is supposed to reject bad input needs a test proving it does — an exception that stops being thrown is a silent behaviour change that no happy-path test will ever notice. Beyond that, keep tests independent and fast: a suite people skip because it takes four minutes is a suite that is not protecting anything.
Syntax
<!-- pom.xml: dependencies and Java version declared once -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
// mvn test | mvn package -> anyone gets an identical build
class InvoiceTest {
@Test
void totalIncludesTaxRoundedToPaise() { // names the BEHAVIOUR
var invoice = new Invoice(List.of(new Line("A", 10_000L))); // arrange
long total = invoice.totalWithTax(0.18); // act
assertEquals(11_800L, total); // assert
}
@Test
void emptyInvoiceTotalsZeroRatherThanThrowing() {
assertEquals(0L, new Invoice(List.of()).totalWithTax(0.18));
}
@Test
void negativeTaxRateIsRejected() {
var invoice = new Invoice(List.of(new Line("A", 100L)));
var e = assertThrows(IllegalArgumentException.class,
() -> invoice.totalWithTax(-0.1));
assertTrue(e.getMessage().contains("tax"));
}
// ^ a method that must REJECT bad input needs a test proving it does.
// An exception that stops being thrown is a silent behaviour change.
@ParameterizedTest
@ValueSource(longs = {0, 1, Long.MAX_VALUE}) // boundaries
void handlesEdgeAmounts(long amount) { ... }
}
// Write a regression test for every bug you fix. That is what stops
// it coming back in six months.
Key points
- A build file makes the project reproducible: one command, identical result on any machine. That is what ends 'it works on mine'.
- The happy path is the least valuable test — you already ran it by hand. Empty, null, boundary, duplicate and error cases are where defects live.
- Use assertThrows for rejection behaviour. Without it, a validation that quietly stops throwing passes every remaining test.
Practice challenge
Student inherits from Person.
Hello, I'm a student
Show a hint
- class Child extends Parent
- Child inherits all parent methods
- Override by redefining method
Check yourself
1. What is the main purpose of a build tool like Maven?
Show answer
B. A reproducible build: declared dependencies and version, identical on any machine
2. Which test is least valuable?
Show answer
B. The happy path you already ran manually
3. Why use assertThrows?
Show answer
B. To prove a method rejects bad input — otherwise a validation that stops throwing goes unnoticed
Common questions
Do I need any background to start Java?
No. This track begins at its own beginning and assumes nothing, and the first lesson explains what the thing is before showing you any syntax.
How long does the Java track take?
About 170 minutes of reading across 10 lessons, plus the practice challenges and 30 multiple-choice questions, which is where the time actually goes.
Is it free?
Yes, and there is no account. Everything runs in your browser.
More: all 15 tracks · what employers actually ask for · the full syllabus