Part I — Software quality and testing · Lesson 1

Software quality, testing and tooling

~30 min of reading, plus collapsed slide detailsJava code checked with JDK 21, JUnit 5.10 and Mockito 5.23

In this lesson

  1. How is the course organised and examined?
  2. What is software quality?
  3. How does bad code show itself?
  4. Which principles lead to good design?
  5. What do DRY, KISS and SOLID look like in code?
  6. How does test-driven development work?
  7. How do refactoring and design patterns improve code?
  8. Does the programming paradigm matter for quality?
  9. How do you test a component that depends on others?
  10. How do you keep your work under version control?
  11. Check your understanding

The lesson in one paragraph. Quality software keeps working while requirements change, and what makes that possible is internal quality: code that is readable, testable and easy to change. The course builds it in layers: the defects to avoid (repetition, rigidity, fragility, opacity…), the software-engineering principles that prevent them, the design principles that make them concrete (DRY, KISS, SOLID), and the design patterns that package them. Tests hold it together: in TDD they are written first and act as the specification, and test doubles let a component be tested in isolation. Git keeps the history.

1. How is the course organised and examined?

PPS (Prof. Mirko Viroli; lab: Dr. Gianluca Aguzzi; tutor: Dr. Nicolas Farabegoli) forms the integrated course Sviluppo di Sistemi Software (SSS) with PCD (Prof. Alessandro Ricci). It has 12–13 weekly modules of 5–6 hours: theory first, then a guided lab, plus a few hours of programming at home. It is a programming-oriented software-engineering course that puts code before process documentation. Its content is OO, functional and logic programming and their combinations, design patterns and refactoring, while the software-engineering topics are shared with PCD. Everything runs on the JVM so that the languages can be integrated: Java for OO, Scala for FP and OO+FP, tuProlog (developed in the Cesena group) for logic programming. The tools are JDK 21, Scala 3.3, tuProlog 4, IntelliJ IDEA with the Scala plugin, Gradle, SBT and Git. LLMs should not be used as the norm: you learn without shortcuts now, so that you can use them well later (ASMD, the following year, covers LLM-aided engineering).

The exam

The exam checks (1) FP concepts; (2) FP vs OOP and fluency in Scala; (3) LP concepts and reasonable fluency in Prolog on simple programs; (4) software-engineering principles and quality programming skills. Top marks need brilliance in some of them. Two options:

The exam is project evaluation, project discussion, and additional FP/LP questions; the final grade is 50% PPS, 50% PCD. Budget: 60 h class and lab, 60 h project, 30 h study; ideally start the project in May and pass in July. This lesson matters for the exam mainly through the project: it is how you test, design and document your own code, not a source of concept questions.

Prerequisites: a good grasp of OO and fluent Java; software-engineering basics (process, UML) and Java lambdas help.

From the slides: the planned module structure and the reading list

Parts and modules. A, quality software production: 01a quality programs (mechanisms and criteria), 01b techniques for quality (patterns, testing, refactoring, abstraction), 01c testing · B, functional programming: 02 basic FP constructs, 03 structures, lambdas, laziness, streams, 04 module types, type classes, monads · C, mixing OO and FP in Scala: 05 OO in Scala, 06 basic OO+FP techniques, 07 advanced mechanisms (typing, basic FP patterns) · D, methodologies: 08 software methodologies (agile, Scrum), 08c test-driven development (JUnit, ScalaTest, ScalaCheck), 09 advanced development tools (continuous integration) · E, logic programming: 10 unification, resolution, programming, 11a coding and practice, 11b Java–Scala–Prolog integration · F, seminars: 12 OO and functional patterns, functional libraries for concurrency. In these notes: Part A is this lesson, B is lessons 2–4, C is lessons 5–7, the methodology is lesson 8, and logic programming and integration are lessons 9–11.

Books. Advanced OO: Bloch, Effective Java (3rd ed.); Gamma, Helm, Johnson, Vlissides, Design Patterns; Mycroft, Fusco, Urma, Modern Java in Action. FP: Odersky, Programming in Scala (4th ed.); Payne, Wampler, Programming Scala (2021); Chiusano, Bjarnason, Functional Programming in Scala; Bevilacqua-Linn, Functional Programming Patterns in Scala and Clojure. LP: Sterling, Shapiro, The Art of Prolog (2nd ed.). Software engineering: Ghezzi, Jazayeri, Mandrioli, Fundamentals of Software Engineering; Martin, Clean Code; Shore, The Art of Agile Development; Kniberg, Scrum and XP from the Trenches; Beck, Test Driven Development: By Example; Tahchiev et al., JUnit in Action (2nd ed.); Hinojosa, Testing in Scala.

2. What is software quality?

Why software needs a process

Software is about 15% of world GDP (indirectly) and heading for 30% by 2030, yet over the whole life cycle productivity is far below 100 lines per person per day. Software is easy to “somewhat prototype” but hard to get right and to debug, and without discipline projects fail. Development iterates over four kinds of step (lesson 8 covers processes): requirement engineering (elicit, analyse, specify), design (from architecture to details), implementation, and post-implementation (testing, deployment, maintenance: the most expensive phase).

From the slides: Glass’s “frequently forgotten facts”

A 10% increase in problem complexity brings a 100% increase in solution complexity. Good programmers are up to 30 times better than mediocre ones. Projects run away because of unstable requirements and optimistic estimates. Finding and removing errors takes 40% of development effort; rigorous review removes 90% of errors. Maintenance takes 40–80% of the total cost.

Quality software adapts better to unstable requirements, lowers maintenance costs and helps remove errors. Getting there means knowing what quality is, using practices that lead to it, and organising the process well: the goals of the course. Behind all of them is one skill, managing abstraction: the single concept behind many concrete cases. In code it mitigates opacity and underlies DRY (don’t repeat yourself) and DIP (depend on interfaces, not on implementation classes).

Definitions and measures

Quality is “translating future needs into measurable characteristics” (Deming) or “meeting the customer needs, with freedom from deficiencies” (Juran), seen from a transcendental (elegance), product (measurable characteristics), manufacturing (conformance) or user (satisfaction) perspective. It is measured as reliability, efficiency, security, maintainability and size.

Internal and external quality

External quality decides whether stakeholder requirements are met: correctness, usability, efficiency, reliability, integrity, adaptability, accuracy, robustness. Internal quality decides whether the team can keep moving forward: maintainability, flexibility, portability, reusability, readability, testability, understandability. People notice external quality, but internal quality is the means to it, and skipping it is a common cause of failure; modern approaches make it observable through tests, reviews and clean code. Code quality, the shape of the code and how it can evolve, is a precondition of both.

Technical debt

Technical debt is the extra work caused by choosing code that is easy now over the best overall solution: the “patch” you mean to improve later, which ends up costing more than fixing it at once. It is sometimes unavoidable, and it can only be managed empirically, since the distance from “complete quality” is almost impossible to measure.

public class Configuration {

  private List<String> data;

  public void loadFromFile(final String fileName) {
    try {
      this.data = Files.readAllLines(Paths.get(fileName));
    } catch (IOException e) {
      System.out.println(e); // Technical debt here!
    }
  }

  public List<String> getData() {
    return Collections.unmodifiableList(this.data);
  }
}

With a missing file, loadFromFile prints java.nio.file.NoSuchFileException: missing.txt and returns normally; the failure surfaces later, elsewhere, as a NullPointerException in getData(). The fix is to propagate the exception or choose an explicit fallback.

3. How does bad code show itself?

The course organises quality in five layers, and this lesson follows them: code defects to avoid (this section), software-engineering principles (section 4), design principles DRY, KISS and SOLID (section 5), design patterns (section 7), and methodology: waterfall and agility, with TDD (section 6, lesson 8).

Code smells

A code smell is a surface sign that may point to a deeper problem: duplicated code, bad names, abstraction “jumps”, partial implementations, violated OO principles. Smells are not necessarily bugs, but they create technical debt. Three families are common: neglected conventions (in Java: no I prefixes, camel case, one declaration per line, no obscure operators); ignored rules of effectiveness from Bloch’s Effective Java (know your libraries, minimise mutability and accessibility, favour composition over inheritance, program against interfaces…); and unclean code in Martin’s sense: code should read like good prose and always reveal its intent. Names come first: intention-revealing (elapsedTimeInDays, not d), pronounceable, searchable (MAX_CLASSES_PER_STUDENT), nouns for classes and verbs for functions.

From the slides: the full convention, Effective Java and naming rules

Conventions. Every language has conventions beyond its syntax (naming, spacing, formatting, obsolete constructs, program structure); they differ between languages (C# vs Java), and groups may add constraints but never remove them. In Java: never prefix interfaces or variables, use camel case for all identifiers, never declare two variables on one line, don’t abuse obscure operators.

Effective Java rules. Know and use your libraries; minimise the accessibility of class members; minimise mutability; favour composition over inheritance; design and document for inheritance, or else prohibit it; prefer interfaces to abstract classes; check the validity of parameters in public APIs; document all public APIs; make defensive copies when needed; minimise the scope of local variables; prefer for-each loops; avoid floating point when exact values are needed; avoid strings where other types fit better; program against interfaces, not classes.

Names.

Spot what is wrong with this interface:

// A modelling abstraction for a DVCS
public interface Model {

  void setCmd();

  void doCopy(String path2);

  void setRepository(String repo);

  boolean check_Existence();

  void doCommit(String commento);

  String[] Log();
}
Answers: seven problems

Functions, comments, formatting

A function should be small (about 10 lines), do one thing at one level of abstraction, read top-down like a story, take 0–2 arguments (wrap more into classes or builders), and have no side effects unless that is its purpose. The course’s example is a 38-line Clean Code function that repeats a lookup–render–append sequence four times; refactored, it becomes a class of small methods whose top level reads as a sentence:

private void includeSetupAndTeardownPages() throws Exception {
  includeSetupPages();
  includePageContent();
  includeTeardownPages();
  updatePageContent();
}
Beyond the slides

Run against stand-ins for the FitNesse classes, the two versions differ: the refactored one adds a newline before every !include. A refactoring must not change behaviour, and one output-comparing test would have caught it.

From the slides: the full Clean Code example, before and after

Before: the “what” (setup, content, teardown) is mixed with string building and page lookup.

public static String testableHtml(PageData pageData, boolean includeSuiteSetup) throws Exception {
  WikiPage wikiPage = pageData.getWikiPage();
  StringBuffer buffer = new StringBuffer();
  if (pageData.hasAttribute("Test")) {
    if (includeSuiteSetup) {
      WikiPage setup = PageCrawlerImpl.getInheritedPage(Responder.SETUP_NAME, wikiPage);
      if (setup != null) {
        WikiPagePath pagePath = setup.getPageCrawler().getFullPath(setup);
        String pagePathName = PathParser.render(pagePath);
        buffer.append("!include -setup .").append(pagePathName).append("\n");
      }
    }
    WikiPage setup = PageCrawlerImpl.getInheritedPage("SetUp", wikiPage);
    if (setup != null) {
      WikiPagePath setupPath = wikiPage.getPageCrawler().getFullPath(setup);
      String setupPathName = PathParser.render(setupPath);
      buffer.append("!include -setup .").append(setupPathName).append("\n");
    }
  }
  buffer.append(pageData.getContent());
  if (pageData.hasAttribute("Test")) {
    WikiPage teardown = PageCrawlerImpl.getInheritedPage("TearDown", wikiPage);
    if (teardown != null) {
      WikiPagePath tearDownPath = wikiPage.getPageCrawler().getFullPath(teardown);
      String tearDownPathName = PathParser.render(tearDownPath);
      buffer.append("\n").append("!include -teardown .").append(tearDownPathName).append("\n");
    }
    if (includeSuiteSetup) {
      WikiPage tearDown = PageCrawlerImpl.getInheritedPage(Responder.TEARDOWN_NAME, wikiPage);
      if (tearDown != null) {
        WikiPagePath pagePath = tearDown.getPageCrawler().getFullPath(tearDown);
        String pagePathName = PathParser.render(pagePath);
        buffer.append("!include -teardown .").append(pagePathName).append("\n");
      }
    }
  }
  pageData.setContent(buffer.toString());
  return pageData.getHtml();
}

After: each method sits at one level of abstraction, and the repeated sequence is a single include.

public class SetupTeardownIncluder {
  private PageData pageData;
  private boolean isSuite;
  private WikiPage testPage;
  private StringBuffer newPageContent;
  private PageCrawler pageCrawler;

  public static String render(PageData pageData) throws Exception {
    return render(pageData, false);
  }
  public static String render(PageData pageData, boolean isSuite) throws Exception {
    return new SetupTeardownIncluder(pageData).render(isSuite);
  }
  private SetupTeardownIncluder(PageData pageData) {
    this.pageData = pageData;
    testPage = pageData.getWikiPage();
    pageCrawler = testPage.getPageCrawler();
    newPageContent = new StringBuffer();
  }
  private String render(boolean isSuite) throws Exception {
    this.isSuite = isSuite;
    if (isTestPage()) {
      includeSetupAndTeardownPages();
    }
    return pageData.getHtml();
  }
  private boolean isTestPage() throws Exception {
    return pageData.hasAttribute("Test");
  }
  private void includeSetupAndTeardownPages() throws Exception {
    includeSetupPages();
    includePageContent();
    includeTeardownPages();
    updatePageContent();
  }
  private void includeSetupPages() throws Exception {
    if (isSuite) {
      includeSuiteSetupPage();
    }
    includeSetupPage();
  }
  private void includeSuiteSetupPage() throws Exception {
    include(SuiteResponder.SUITE_SETUP_NAME, "-setup");
  }
  private void includeSetupPage() throws Exception {
    include("SetUp", "-setup");
  }
  private void includePageContent() throws Exception {
    newPageContent.append(pageData.getContent());
  }
  private void includeTeardownPages() throws Exception {
    includeTeardownPage();
    if (isSuite) {
      includeSuiteTeardownPage();
    }
  }
  private void includeTeardownPage() throws Exception {
    include("TearDown", "-teardown");
  }
  private void includeSuiteTeardownPage() throws Exception {
    include(SuiteResponder.SUITE_TEARDOWN_NAME, "-teardown");
  }
  private void updatePageContent() throws Exception {
    pageData.setContent(newPageContent.toString());
  }
  private void include(String pageName, String arg) throws Exception {
    WikiPage inheritedPage = findInheritedPage(pageName);
    if (inheritedPage != null) {
      String pagePathName = getPathNameForPage(inheritedPage);
      buildIncludeDirective(pagePathName, arg);
    }
  }
  private WikiPage findInheritedPage(String pageName) throws Exception {
    return PageCrawlerImpl.getInheritedPage(pageName, testPage);
  }
  private String getPathNameForPage(WikiPage page) throws Exception {
    WikiPagePath pagePath = pageCrawler.getFullPath(page);
    return PathParser.render(pagePath);
  }
  private void buildIncludeDirective(String pagePathName, String arg) {
    newPageContent.append("\n!include ")
                  .append(arg)
                  .append(" .")
                  .append(pagePathName)
                  .append("\n");
  }
}

Comments mostly patch bad code that should be rewritten instead; write Javadoc for public APIs, never comment code out. Formatting follows the group’s practice: blank lines between concepts, callers above callees, 100–120 characters per line. Bad code costs maintenance, and “later equals never” (Leblanc’s law); good code is tested, reviewed, refactored and reads like prose. Leave the campground cleaner than you found it.

The seven code defects

DefectSymptom
Needless repetition (against DRY)cut-paste-modify: bugs and changes multiply from one cause
Needless complexity (against KISS)more involved than needed: harder to understand, change, test
Rigidityhard to change: small changes cascade
Fragilityeasy to break: a change here causes errors elsewhere
Immobilityhard to reuse: looks general, costly to extract
Viscosityhacking is easier than doing it right
Opacityhard to understand: intent hidden
From the slides: symptoms and typical causes of each defect

Two examples, with our rewrites (checked to give the same results):

// Needless repetition: the two branches differ only in the image used
private Enemy createEnemy() {
  int type = rand.nextInt(2);
  if (type == 0) {
    return new Cactus(800, cactus1.getWidth() - 10,
            cactus1.getHeight() - 10, cactus1);
  } else {
    return new Cactus(800, cactus2.getWidth() - 10,
            cactus2.getHeight() - 10, cactus2);
  }
}

// DRY: choose the varying part, then build once
private Enemy createEnemy() {
  Image image = rand.nextInt(2) == 0 ? cactus1 : cactus2;
  return new Cactus(800, image.getWidth() - 10, image.getHeight() - 10, image);
}
public class StatValuesImpl implements StatValues {
  private static final int NO_MAX = -1;
  private int actual;
  private int max;

  public int getActual() { return actual; }
  public int getMax() { return max; }

  // Needless complexity: nested ifs for what is a clamp into [0, max]
  public void updateActual(int value) {
    if (actual + value <= 0) {
      actual = 0;
    } else {
      if (max != NO_MAX && actual + value > max) {
        actual = max;
      } else {
        actual = actual + value;
      }
    }
  }

  // KISS: the same behaviour (checked for max = NO_MAX and max >= 0)
  public void updateActualSimple(int value) {
    int upper = max == NO_MAX ? Integer.MAX_VALUE : max;
    actual = Math.max(0, Math.min(upper, actual + value));
  }
}

The slide version of StatValuesImpl does not compile (private in max, getters without parentheses); the version above fixes both typos.

4. Which principles lead to good design?

The seven software-engineering principles

Everyone agrees with these principles and then believes there is no time for them; the real skill is knowing when to apply each. Lesson 8 uses them from the methodology side (its deck abbreviates them RF, SOC, M, A, AOC, G, I).

PrincipleWhat it asksExample
RF — rigour and formalitydescribe behaviour as precisely as mathematics would, where errors would be costlystate machines; rule-like specifications in tests
SOC — separation of concernsgroup issues and consider each in isolation (in time, by quality, by view)unit / integration / acceptance tests; functional vs non-functional requirements
MOD — modularitymodules with high cohesion inside and low coupling between thempackages, nested classes, JARs
ABS — abstractionkeep the key aspects, ignore the unnecessary detailsan interface in front of a class; an API layer; a DSL
AOC — anticipation of changeprepare to react flexibly to likely changesconstants instead of magic numbers; creation hidden behind factories
GEN — generalitysolve a more general problem than the one at handan implementation choice turned into a parameter
INC — incrementalityproceed in small increments (a quality of processes)version numbers, Sprints, 20-minute TDD cycles
From the slides: each principle in full, with all its examples

The OO paradigm as the starting point

Paradigms and languages affect quality, and OO is the mainstream: every top TIOBE language except C is OO, the complex part of large systems is usually written in Java or C#, and GoF patterns and UML assume OO. Its elements: an object has identity, state (its fields) and behaviour; a method handles a request–reply message; an interface is a contract, i.e. a type; a class implements an interface and is a template for objects. Its relationships: dependency (use-a); association, aggregation, composition (has-a: independent lives without ownership, independent lives with ownership, or B dies with A); inheritance (is-a, with subtyping and dynamic dispatch); implementation (conforms-to, also a subtype relation).

The course’s first example, a controller of devices, uses all of these:

public interface Device {
  void on();
  void off();
  boolean isOn();
}

public interface Controller {
  void addDevice(Device device);
  void setAllOn();
  void setAllOff();
  boolean allOn();
}

public class TV implements Device {                 // implementation
  private boolean flag = true;
  @Override public void on() { flag = true; }
  @Override public void off() { flag = false; }
  @Override public boolean isOn() { return flag; }
  @Override public String toString() { return "TV{" + flag + "}"; }
}

public class EternalTV extends TV {                 // inheritance
  @Override public void off() {}                    // refuses to switch off
  @Override public String toString() { return "eTV{on}"; }
}

public class ControllerImpl implements Controller {
  private final List<Device> devices = new LinkedList<>();   // association

  @Override public void addDevice(final Device device) { devices.add(device); }
  @Override public void setAllOn() { for (final Device d : devices) { d.on(); } }
  @Override public void setAllOff() { for (final Device d : devices) { d.off(); } }
  @Override public boolean allOn() {
    for (final Device d : devices) {
      if (!d.isOn()) { return false; }
    }
    return true;
  }
  @Override public String toString() { return "ControllerImpl{devices=" + devices + '}'; }
}

final Controller controller = new ControllerImpl();
controller.addDevice(new TV());
controller.addDevice(new EternalTV());
controller.addDevice(new TV());
controller.setAllOff();
System.out.println(controller);        // ControllerImpl{devices=[TV{false}, eTV{on}, TV{false}]}
System.out.println(controller.allOn()); // false

Dynamic dispatch picks EternalTV.off(), so after setAllOff() one device is still on and the controller, which only sees the interface, cannot tell. Whether that is an acceptable Device is the question the Liskov principle answers.

5. What do DRY, KISS and SOLID look like in code?

DRY (don’t repeat yourself): cut-paste-modify creates hidden dependencies through which errors propagate, and makes the system rigid; duplication is the quintessential code smell. KISS (keep it simple, stupid; U.S. Navy, 1960): never use a complex solution when a simpler one exists, and don’t solve problems nobody asked about.

From the slides: DRY and KISS in more detail

Refactoring removes duplication, tools can detect syntactic duplication, and some paradigms such as FP apply DRY very aggressively. KISS in other words: Occam’s razor (the simpler solution tends to be right), Leonardo (“simplicity is the ultimate sophistication”), Mies van der Rohe (“less is more”), Stroustrup (“make simple tasks simple”), Saint-Exupéry (“perfection is reached when there’s nothing left to take away”), Einstein (“as simple as possible, but no simpler”). When a complex solution emerges, think twice.

SOLID names five principles that generalise OO practice; the course applies them across paradigms.

PrincipleStatementWhat it means in practice
SRP — single responsibilityA component should have one, and only one, reason to change.small, coherent components; encapsulate away what may change
OCP — open/closedComponents are open for extension, closed for modification.working code is extended, never modified
LSP — Liskov substitutionExtended code must be substitutable for its base version.interchangeable in behaviour, not just in types
ISP — interface segregationMake fine-grained abstractions that are client-specific.one view per kind of client
DIP — dependency inversionDepend on abstractions, not on concretions.expose only abstractions; no abstraction “jumps”

One logger, eight versions

The course applies each principle in turn to a tiny logger. The starting point duplicates the format logic:

public class Logger {
  public static void logPrintString(final String s) {
    System.out.format("[Print] %s\n", s);
  }
  public static void logPrintStrings(final String[] array) {
    for (int i = 0; i < array.length; i++) {
      System.out.format("[Print] %s\n", array[i]);
    }
  }
}

public class LoggerDry {                      // DRY: reuse the single-string version
  public static void logPrintString(final String s) {
    System.out.format("[Print] %s\n", s);
  }
  public static void logPrintStrings(final String[] array) {
    for (int i = 0; i < array.length; i++) {
      logPrintString(array[i]);
    }
  }
}

public class LoggerKiss {                     // KISS: plainer API, plainer loop
  public static void logPrintString(final String s) {
    System.out.println("[Print] " + s);
  }
  public static void logPrintStrings(final String[] array) {
    for (final String s : array) {
      logPrintString(s);
    }
  }
}

public class LoggerSrp {                      // SRP: the prefix is someone else's job
  public static void logPrintString(final String s) {
    System.out.println(PrefixFormatter.printPrefix() + " " + s);
  }
  public static void logPrintStrings(final String[] array) {
    for (final String s : array) {
      logPrintString(s);
    }
  }
}
class PrefixFormatter {
  static String printPrefix() { return "[Print]"; }
}

All four print [Print] a and [Print] b on two lines. OCP makes the logger final and moves the varying part into a formatter changed by extension (overriding printPrefix):

public final class LoggerOcp {
  private final LogFormatter logFormatter;

  public LoggerOcp(LogFormatter logFormatter) { this.logFormatter = logFormatter; }

  // this method will hardly ever change
  public void logPrintString(final String s) {
    System.out.print(logFormatter.message(s));
  }
  public void logPrintStrings(final String[] array) {
    for (final String s : array) {
      logPrintString(s);
    }
  }
}
// changed by extension, not by modification
class LogFormatter {
  protected String printPrefix() { return "[Print]"; }
  public final String message(String s) { return printPrefix() + " " + s; }
}

public final class LoggerIsp {
  // ... same fields and logPrintString as LoggerOcp ...
  // ISP: any Iterable (a List, a Set, Arrays.asList(array)) instead of an array
  public final void logPrintStrings(final Iterable<String> strings) {
    for (final String s : strings) {
      logPrintString(s);
    }
  }
}
public class LoggerDip {
  interface Console {                // abstracts System.out
    void print(String s);
    void println(String s);
  }
  interface Formatter {              // abstracts LogFormatter
    String message(String s);
  }

  private final Formatter formatter;
  private final Console console;

  public LoggerDip(Formatter formatter, Console console) {
    this.formatter = formatter;
    this.console = console;
  }
  public void logPrintString(final String s) {
    console.print(formatter.message(s));
  }
  public void logPrintStrings(final Iterable<String> strings) {
    for (final String s : strings) {
      logPrintString(s);
    }
  }
}

// LSP: what if an implementor breaks the relation between print and println?
// Fix it once, in a base class, with a final template method.
abstract class AbstractConsole implements LoggerDip.Console {
  public final void println(String s) {
    this.print(s);
    this.lineFeed();
  }
  protected abstract void lineFeed();
}
class SystemOutConsole extends AbstractConsole {
  @Override public void print(String s) { System.out.print(s); }
  @Override protected void lineFeed() { System.out.println(); }
}

With DIP the logger knows neither System.out nor a concrete formatter, so a test can pass a recording console and a lambda formatter: new LoggerDip(s -> "[Print] " + s + "\n", new SystemOutConsole()) prints the two lines again. LSP protects the Console contract: println is final, so it always means “print, then end the line”.

Beyond the slides: the refactoring changed the behaviour

Running the slide code shows that LoggerOcp and LoggerIsp print [Print] a[Print] b on a single line. LoggerSrp used println, while the OCP version calls System.out.print on a message that has no line break. A refactoring is supposed to leave behaviour unchanged, and one test on the output would have caught the difference. It is also the same print-versus-println confusion that the LSP step then guards against.

6. How does test-driven development work?

The course is agile throughout (the Agile Manifesto and the course’s six-step method are in lesson 8), and TDD is where agility meets code.

The rules

The three laws of TDD:

  1. You may not write production code until you have written a failing unit test.
  2. You may not write more of a unit test than is sufficient to fail, and not compiling is failing.
  3. You may not write more production code than is sufficient to pass the currently failing test.

Each cycle lasts 10–20 minutes: write a failing test (red), make it pass (green), clean up what the two steps added (refactor).

RED GREEN REFACTOR write a test for thenext behaviour:it fails write the simplestcode that makesit pass clean the design,remove duplication;tests stay green failspasses one cycle: 10–20 minutes · the tests are the specification
The TDD cycle. Design work happens in the refactoring step, protected by tests that are already green.

Why so strict? Tests written first are a specification that documents the code; they cut the cost of finding bugs; and fast tests give the freedom to refactor. The traditional cycle is design → code → test; TDD’s is test → code → refactor, and in practice everybody sits somewhere in between. The lab’s point: TDD fixes the what before the how, and the same JUnit code serves as specification, then implementation guide, then regression check.

Kinds of tests and clean tests

Tests come as unit (one piece of code in isolation), integration (interacting components), functional (the public API, use cases), stress (realistic workload, resources) and acceptance (the whole user experience). Unit tests serve regression, progress measurement, specification, implementation guidance (TDD), solidity (edge cases) and legacy recovery. Test code must be as clean as production code: one naming convention, the build–operate–check (Arrange–Act–Assert) structure, preferably one assert and always a single concept per test (not one test per method), and tests that are fast, independent, repeatable, self-validating and timely.

JUnit 5 in brief

Tests are methods annotated @Test; @BeforeEach builds the fixture; assertions such as assertEquals(expected, actual), assertTrue and assertThrows check the outcome. The lab slide writes assertEqual and assertNotEqual; the methods are assertEquals and assertNotEquals.

From the slides: the JUnit 5 annotations and assertions
Annotation (org.junit.jupiter.api)Meaning
@Testa test method
@BeforeEach / @AfterEachrun before / after each test method
@BeforeAll / @AfterAllrun once before / after all test methods (static methods)
@Disableddisables a test class or method
@Timeoutfails a test that runs longer than the given duration

Assertions (org.junit.jupiter.api.Assertions): assertEquals(expected, actual) / assertNotEquals; assertTrue, assertFalse; assertNull, assertNotNull; assertSame(expected, actual) (same object); assertThrows(Class<T>, Executable), which returns the exception; fail(). Later examples also use @DisplayName, @Nested (inner classes grouping tests), @ParameterizedTest and assertAll.

A worked example: devices and a device manager

The interfaces come first, with their documentation; then (1) generate default implementations, (2) generate one test class per class, (3) complete the implementation test-first. Tests live in the same package, in a different source folder, and are refactored together with the code. A device can be switched on and off, and once it stops working it stays off.

/**
 * An interface modelling a Device, which can be on/off.
 * It can also be non-working, meaning it is off and cannot be switched on.
 */
public interface Device {
  /** Switches the device on */
  void on();
  /** Switches the device off */
  void off();
  /** @return whether the device is on */
  boolean isOn();
  /** @return whether the device is actually working */
  boolean isWorking();
}

/** An encapsulated manager of devices */
public interface DeviceManager {
  /** Adds a device to be managed */
  void addDevice(Device device);
  /** @return an immutable version of the list of added devices */
  List<Device> devices();
  /** Switches on all the devices that are working */
  void switchAllOn();
  /** Switches off all the devices */
  void switchAllOff();
  /** @return whether all devices are switched on */
  boolean allOn();
  /** @return whether all devices are working */
  boolean allWorking();
}
public class DeviceImplTest {

  private static final int N_OF_ATTEMPTS = 10;
  private DeviceImpl device;

  @BeforeEach
  public void setUp() throws Exception {
    this.device = new DeviceImpl();
  }

  @Test
  public void isInitiallyOff() {
    assertFalse(this.device.isOn());
  }

  @Test
  public void canBeSwitchedOn() {
    this.device.on();
    assertTrue(this.device.isOn());
  }

  @Test
  public void canBeSwitchedOff() {
    this.device.on();
    this.device.off();
    assertFalse(this.device.isOn());
  }

  @Test
  public void canBeSwitchedOnTwice() {
    this.device.on();
    this.device.off();
    this.device.on();
    assertTrue(this.device.isOn());
  }

  @Test
  public void onIsIdempotent() {
    this.device.on();
    this.device.on();
    assertTrue(this.device.isOn());
  }

  @Test
  public void canBeSwitchedOffTwice() {
    this.device.on();
    this.device.off();
    this.device.on();
    this.device.off();
    assertFalse(this.device.isOn());
  }

  @Test
  public void canBeSwitchedMultipleTimes() {
    switchOnAndOffMultipleTimes();
    this.device.on();
    assertTrue(this.device.isOn());
  }

  private void switchOnAndOffMultipleTimes() {
    for (int i = 0; i < N_OF_ATTEMPTS; i++) {
      this.device.on();
      this.device.off();
    }
  }

  @Test
  public void initiallyWorks() {
    assertTrue(this.device.isWorking());
  }

  @Test
  public void typicallyWorks() {
    switchOnAndOffMultipleTimes();
    assertTrue(this.device.isWorking());
  }

  @Test
  public void canStopWorking() {
    switchOnAndOffMultipleTimes();
    this.device.stopWorking();
    assertFalse(this.device.isWorking());
    this.device.on();
    assertFalse(this.device.isWorking());
    assertFalse(this.device.isOn());
  }
}
public class DeviceImpl implements Device {

  private boolean on = false;
  private boolean works = true;

  @Override
  public void on() {
    if (this.isWorking()) {      // a broken device ignores on()
      this.on = true;
    }
  }

  @Override
  public void off() {
    this.on = false;
  }

  @Override
  public boolean isOn() {
    return this.on;
  }

  @Override
  public boolean isWorking() {
    return this.works;
  }

  public void stopWorking() {    // not in the interface: only tests and
    this.works = false;          // code that knows DeviceImpl can call it
    this.on = false;
  }
}
public class DeviceManagerImplTest {

  public static final int SIZE = 3;
  private DeviceManager manager;
  private List<Device> devices;

  @BeforeEach
  public void init() {
    this.manager = new DeviceManagerImpl();
    this.devices = new ArrayList<>();
    for (int i = 0; i < SIZE; i++) {
      final Device d = new DeviceImpl();
      d.on();
      this.devices.add(d);
      this.manager.addDevice(d);
    }
  }

  @Test
  public void devices() {
    assertEquals(this.devices, this.manager.devices());
  }

  @Test
  public void switchAllOn() {
    this.manager.devices().get(0).off();
    this.manager.devices().get(SIZE - 1).off();
    this.manager.switchAllOn();
    assertTrue(this.manager.allOn());
  }

  @Test
  public void switchAllOff() {
    this.manager.devices().get(SIZE - 1).off();
    this.manager.switchAllOff();
    for (final Device device : this.manager.devices()) {
      assertFalse(device.isOn());
    }
  }

  @Test
  public void allOn() {
    for (final Device device : this.manager.devices()) {
      assertTrue(device.isOn());
    }
  }

  @Test
  public void allWorking() {
    for (final Device device : this.manager.devices()) {
      assertTrue(this.manager.allWorking());
    }
  }

  @Test
  public void someNotWorking() {
    final DeviceImpl device = new DeviceImpl();
    device.stopWorking();
    this.manager.addDevice(device);
    assertFalse(this.manager.allWorking());
  }
}
public class DeviceManagerImpl implements DeviceManager {

  private List<Device> devices = new ArrayList<>();

  @Override
  public void addDevice(Device device) {
    this.devices.add(device);
  }

  @Override
  public List<Device> devices() {
    return Collections.unmodifiableList(this.devices);   // no leaks of internal state
  }

  @Override
  public void switchAllOn() {
    this.devices.forEach(Device::on);
  }

  @Override
  public void switchAllOff() {
    this.devices.forEach(Device::off);
  }

  @Override
  public boolean allOn() {
    for (final Device device : this.devices()) {
      if (!device.isOn()) {
        return false;
      }
    }
    return true;
  }

  @Override
  public boolean allWorking() {
    for (final Device device : this.devices()) {
      if (!device.isWorking()) {
        return false;
      }
    }
    return true;
  }
}

All 16 tests pass. Each names one behaviour (isInitiallyOff, onIsIdempotent, canStopWorking…), and devices() returns an unmodifiableList (“minimise mutability” at the API boundary).

Beyond the slides: two tests that test less than their name

The test called allOn never calls manager.allOn() (only switchAllOn does), and allWorking repeats one assertion once per device. “A single concept per test” is worth checking in your own project too.

The labs, and a solution that tests the design

The labs practise TDD on small problems, writing tests as complete usage scenarios with their edge cases, and committing at the right moments of the cycle.

From the slides: the lab 01 and 01b exercises

Lab 01 starts from a working bank-account model and its test (SimpleBankAccountTest). You refactor code and test following the quality principles, then add a withdrawal fee of 1 $ test-first. After that, three problems are solved with TDD, writing tests as complete usage scenarios with edge cases rather than as isolated method checks. The first is a SmartDoorLock (including its locked and blocked states). The second is a MinMaxStack (what is the minimum of an empty stack?). The third is a CircularQueue with fixed capacity that overwrites the oldest element when full; for it you also design the method signatures. Lab 01b refactors two legacy code bases under TDD and optionally builds a minesweeper test-first. The advice for the repository: commit often, at the right moments of the TDD cycle, with good messages.

The solution to the lab’s bank-account fee (repository tdd-solid-bankaccounts) makes the fee a template-method hook, and puts the common tests in an abstract test class that every account type inherits: each subclass must pass its parent’s tests, an executable form of LSP.

public abstract class CoreBankAccount {
  private int balance;

  public void deposit(int amount) {
    this.balance = this.balance + amount;
  }
  public void withdraw(int amount) {
    this.balance = this.balance - amount - feeCalculation(amount);
  }
  protected abstract int feeCalculation(int amount);

  public int getBalance() {
    return this.balance;
  }
}

public class SilverBankAccount extends CoreBankAccount {
  @Override
  protected int feeCalculation(int amount) { return 1; }
}

public class GoldBankAccount extends CoreBankAccount {
  @Override
  protected int feeCalculation(int amount) { return amount > 50 ? 1 : 0; }
}
public abstract class CoreBankAccountTest {
  protected CoreBankAccount account;         // set by each subclass

  @Test
  void canDeposit() {
    int amount1 = 100;
    int amount2 = 20;
    this.account.deposit(amount1);
    this.account.deposit(amount2);
    assertEquals(amount1 + amount2, this.account.getBalance());
  }
}

public class GoldBankAccountTest extends CoreBankAccountTest {
  @BeforeEach
  void init() {
    this.account = new GoldBankAccount();
  }

  @ParameterizedTest
  @CsvSource({
      "100, 20, 80",        // deposit, withdraw, expected balance: no fee up to 50
      "100, 80, 19"         // fee of 1 above 50
  })
  void canWithdraw(int deposit, int withdraw, int expectedBalance) {
    this.account.deposit(deposit);
    this.account.withdraw(withdraw);
    assertEquals(expectedBalance, this.account.getBalance());
  }
}
// SilverBankAccountTest is identical with new SilverBankAccount() and rows "100, 20, 79", "100, 80, 19"

JUnit runs canDeposit for each concrete class and canWithdraw once per CSV row: six tests, all passing.

A preview: tests as speakable specifications in Scala

ScalaTest lets tests read like prose:

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class EmptySetSpecification extends AnyFlatSpec with Matchers:
  "An empty Set" should "have size 0" in {
    Set.empty should have size (0)
  }

  it should "raise NoSuchElementException when head is called" in {
    an [NoSuchElementException] should be thrownBy (Set.empty.head)
  }
EmptySetSpecification:
An empty Set
- should have size 0
- should raise NoSuchElementException when head is called

The slide writes extends FlatSpec without with Matchers; in ScalaTest 3.2 the class is AnyFlatSpec, and the should syntax needs the Matchers mixin.

7. How do refactoring and design patterns improve code?

Refactoring

Refactoring evolves code or tests without changing functionality, to make them more readable and general or to prepare them for change; good tests are what make it safe. The techniques go from fine to coarse: renaming; changing signatures; extracting methods, static methods and constants; replacing inheritance with delegation; moving code into new or base classes; turning constructors into static factories; and finally applying design patterns.

Design patterns

A design pattern packages a recurring design solution, extracted from experience rather than invented. The 23 “Gang of Four” patterns (Gamma, Helm, Johnson, Vlissides) are the classics. A pattern makes code easier to understand (its name explains the structure), more open to change and better organised.

From the slides: the 23 GoF patterns

Creational: Factory Method (class level); Abstract Factory, Builder, Prototype, Singleton (object level). Structural: Adapter (class and object level); Bridge, Composite, Decorator, Facade, Flyweight, Proxy (object level). Behavioural: Interpreter, Template Method (class level); Chain of Responsibility, Command, Iterator, Mediator, Memento, Observer, State, Strategy, Visitor (object level). The slide lists Flyweight among the behavioural patterns; the GoF book classifies it as structural.

The course works through seven of them; in each, the device example meets a new requirement and the pattern absorbs it.

Proxy

A proxy is a surrogate with the same interface that controls access to another object: to create it lazily, to reach a remote object transparently, or to restrict its use (as read-only collections do). It keeps access logic out of the base class (SRP), and clients depending on the interface cannot tell it is there (DIP); proxies can be chained.

From the course code: a proxy that forbids overdrafts on a bank account
public interface BankAccount {
  int balance();
  void deposit(int amount);
  void withdraw(int amount);
}

class SimpleBankAccount implements BankAccount {
  private int balance;

  @Override public int balance() { return this.balance; }

  private void checkNegativeAmount(int amount) {
    if (amount < 0) {
      throw new IllegalArgumentException();
    }
  }
  @Override public void deposit(int amount) {
    checkNegativeAmount(amount);
    this.balance = this.balance + amount;
  }
  @Override public void withdraw(int amount) {
    checkNegativeAmount(amount);
    this.balance = this.balance - amount - 1;       // fee of 1
  }
}

class CheckedBankAccount implements BankAccount {    // the proxy
  private final BankAccount baseBankAccount = new SimpleBankAccount();

  public int balance() { return baseBankAccount.balance(); }
  public void deposit(int amount) { baseBankAccount.deposit(amount); }
  public void withdraw(int amount) {
    if (amount > this.balance()) {                  // access control
      throw new IllegalStateException();
    }
    baseBankAccount.withdraw(amount);
  }
}

An abstract test with a factory method createBankAccountToTest() holds the common tests. CheckedBankAccountTest inherits them and adds cannotWithdrawMoreThanDeposited, so the proxy passes every test of the base account plus its own.

Strategy

Strategy: an algorithm that will change or come in versions is captured by an interface, and the class receives an implementation in its constructor or a setter (SRP and DIP; lambdas make it cheap; e.g. Comparator for TreeSet). When devices get names, the manager takes a search predicate instead of hard-coding a search by name:

// before: one hard-coded search
public List<Device> devicesByName(String s) {
  List<Device> list = new ArrayList<>();
  for (Device device : devices) {
    if (device.getName().equals(s)) {
      list.add(device);
    }
  }
  return Collections.unmodifiableList(list);
}

// after: the criterion is a (functional) strategy
public List<Device> filteredDevices(Predicate<Device> predicate) {
  // or: return this.devices.stream().filter(predicate).collect(Collectors.toList());
  List<Device> list = new ArrayList<>();
  for (Device device : devices) {
    if (predicate.test(device)) {
      list.add(device);
    }
  }
  return Collections.unmodifiableList(list);
}

// with devices lamp, tv (on), lamp:
manager.filteredDevices(d -> d.getName().equals("lamp")).size();                 // 2
manager.filteredDevices(Device::isOn).size();                                     // 1
manager.filteredDevices(d -> d.getName().startsWith("t") && !d.isOn()).size();   // 0
From the course code: the bank-account fee as a strategy

The fee becomes a function passed to a single implementation, and a factory decides which fee each account type gets:

public class BankAccountImpl implements BankAccount {
  private int balance;
  private final Function<Integer, Integer> fee;     // the strategy

  public BankAccountImpl(Function<Integer, Integer> fee) { this.fee = fee; }

  @Override public int balance() { return this.balance; }
  @Override public void deposit(int amount) { this.balance = this.balance + amount; }
  @Override public void withdraw(int amount) {
    this.balance = this.balance - amount - fee.apply(amount);
  }
}

public class BankAccountFactoryImpl implements BankAccountFactory {
  @Override public BankAccount createGoldBankAccount() {
    return new BankAccountImpl(amount -> 0);
  }
  @Override public BankAccount createSilverBankAccount() {
    return new BankAccountImpl(amount -> amount > 100 ? 1 : 0);
  }
}

In the template-method version (section 6) the fee is fixed by a subclass at compile time; here it is an object chosen at run time.

Template Method

Template Method solves the same problem by inheritance: the varying steps are abstract methods called by a concrete template method, so the choice is made at compile time (e.g. InputStream). A breakable device would duplicate all of DeviceImpl; a base class factors the common code:

public abstract class AbstractDevice implements Device {
  private boolean on;
  private boolean working;
  private final String name;

  public AbstractDevice(String name) {
    this.on = false;
    this.working = true;
    this.name = name;
  }

  @Override public void on() {
    if (this.isWorking()) {
      this.on = true;
    }
  }

  @Override public void off() {       // the template method
    this.doWhenOff();                  // the variable step
    this.on = false;
  }

  protected abstract void doWhenOff();

  @Override public boolean isOn() { return this.on; }
  @Override public boolean isWorking() { return this.working; }
  @Override public String getName() { return this.name; }

  void stopWorking() { this.working = false; }   // package-private
}

public class DeviceImpl extends AbstractDevice {
  public DeviceImpl(String name) { super(name); }

  @Override protected void doWhenOff() { }       // nothing special
}

public class BrokenDeviceImpl extends AbstractDevice {
  public BrokenDeviceImpl(String name) { super(name); }

  @Override protected void doWhenOff() {         // 5% chance of breaking
    if (this.isOn() && Math.random() > 0.95) {
      this.stopWorking();
    }
  }
}

Decorator

Decorator: to mix features freely, a decorator implements the interface, wraps another object of the same interface and forwards to it; subclasses add or change behaviour, and decorations chain at run time (e.g. Stream). For devices with intensity and breakable devices in any combination, the forwarding is factored into a base DeviceDecorator:

public class DeviceDecorator implements Device {      // forwards everything
  protected final Device device;

  public DeviceDecorator(Device device) { this.device = device; }

  public void on() { device.on(); }
  public void off() { device.off(); }
  public boolean isOn() { return device.isOn(); }
  public boolean isWorking() { return device.isWorking(); }
  public String getName() { return device.getName(); }
}

public class DeviceWithIntensityImpl extends DeviceDecorator {   // adds a feature
  private int intensity;

  public DeviceWithIntensityImpl(Device device) { super(device); }

  public void increaseIntensity() { this.intensity = this.getIntensity() + 1; }
  public int getIntensity() { return intensity; }
}

public class BrokenDeviceImpl extends DeviceDecorator {          // changes a behaviour
  private boolean broken = false;

  public BrokenDeviceImpl(Device device) { super(device); }

  @Override public void on() {
    if (!broken) { super.on(); }
  }
  @Override public void off() {
    if (isOn() && Math.random() > 0.95) { broken = true; }
    super.off();
  }
  @Override public boolean isWorking() { return !broken && super.isWorking(); }
}

Device d1 = new DeviceImpl("A");
Device d2 = new DeviceWithIntensityImpl(new DeviceImpl("A"));
Device d3 = new BrokenDeviceImpl(new DeviceImpl("A"));
Device d4 = new DeviceWithIntensityImpl(
              new BrokenDeviceImpl(
                new DeviceImpl("A")));
Beyond the slides

In the slides and the course code, the decorator BrokenDeviceImpl only forwards calls, so it never breaks: it cannot reach the wrapped object’s package-private stopWorking(). Our version keeps its own broken flag. Run as d4, it reports intensity 2 after two increaseIntensity() calls, and once broken, on() leaves it off.

Adapter

Adapter: a thin class implements the interface a client expects by delegating to existing code (e.g. Arrays.asList); it often compensates for missing design or wraps legacy code. A Client written against its own MyDeviceManager is left untouched (OCP):

public interface MyDeviceManager {          // what the legacy client expects
  void addSomeDevices(List<Device> device);
  void switchOn(int position);
  void switchOff(int position);
}

public class Client {
  final MyDeviceManager manager;

  public Client(MyDeviceManager manager) { this.manager = manager; }

  void prepare() {
    this.manager.addSomeDevices(Arrays.asList(new DeviceImpl("A"),
                                              new DeviceImpl("B")));
    this.manager.switchOn(0);
    this.manager.switchOff(1);
  }
}

public class MyDeviceManagerAdapter implements MyDeviceManager {
  private final DeviceManager manager;

  public MyDeviceManagerAdapter(DeviceManager manager) { this.manager = manager; }

  @Override public void addSomeDevices(List<Device> list) {
    for (Device dev : list) {
      this.manager.addDevice(dev);
    }
  }
  @Override public void switchOn(int position) { this.manager.devices().get(position).on(); }
  @Override public void switchOff(int position) { this.manager.devices().get(position).off(); }
}

// new Client(new MyDeviceManagerAdapter(manager)).prepare() leaves A on and B off

Abstract Factory

Abstract Factory: when which classes to instantiate is decided dynamically, a factory interface has one method per product and each implementation picks the concrete classes, separating construction from use. Factory Method is the one-method special case.

public interface DeviceFactory {
  Device createStandardDevice(String name);
  Device createBrokenDevice(String name);
  Device createRandomDevice(String name);
}

public final class DeviceFactoryImpl implements DeviceFactory {
  @Override public Device createStandardDevice(String name) { return new DeviceImpl(name); }
  @Override public Device createBrokenDevice(String name) { return new BrokenDeviceImpl(name); }
  @Override public Device createRandomDevice(String name) {
    return Math.random() > 0.5 ? createStandardDevice(name) : createBrokenDevice(name);
  }
}

DeviceFactory factory = new DeviceFactoryImpl();
Device d1 = factory.createStandardDevice("A");
Device d2 = factory.createBrokenDevice("A");

Builder

Builder: a companion object takes over complex construction (many or optional inputs, defaults), usually with a fluent interface, replacing telescoping constructors (e.g. StringBuilder):

public class DeviceImpl implements Device {
  // ...
  public DeviceImpl() { this("Device"); }                      // before: three constructors
  public DeviceImpl(String name) { this(name, ""); }
  public DeviceImpl(String name, String description) { /* ... */ }
}

public class DeviceImplBuilder {
  private String name = "Device";
  private String description = "";

  public DeviceImplBuilder setName(String name) {
    this.name = name;
    return this;
  }
  public DeviceImplBuilder setDescription(String description) {
    this.description = description;
    return this;
  }
  public DeviceImpl createDeviceImpl() {
    return new DeviceImpl(name, description);
  }
}

Device d1 = new DeviceImplBuilder().setName("A")
                                   .setDescription("a nice device")
                                   .createDeviceImpl();
Device d2 = new DeviceImplBuilder().setName("A").createDeviceImpl();   // description ""
Device d3 = new DeviceImplBuilder().createDeviceImpl();                // name "Device"

8. Does the programming paradigm matter for quality?

Quality must be achieved in any language, but the right paradigm can capture the right abstraction and so becomes a tool for quality. A paradigm is a fundamental style of programming, resting on three mechanisms: primitive expressions, means of combination and means of abstraction. There are many languages, but few paradigms.

ParadigmMottoComputing as…Languages
ImperativeFirst do this, then do thatexecuting statements that change a state, organised by structured constructs (for, while), procedures and modules. Inspired by the hardware, considered the most “natural” for beginners, and the starting point of OO.Fortran, Algol, Pascal, Basic, C
Object-orientedSend a message to an object to simulate a real-world eventobjects exchanging messages, each encapsulating data and behaviour (classes, methods, fields). Method bodies are usually imperative. Considered the most “powerful” general-purpose approach.C++, Java, C#
FunctionalEvaluate an expression and use the resulting valueapplying functions without side effects, with recursion instead of iteration. Inspired by mathematics and the λ-calculus, considered the most “elegant” approach by experts, later mixed with OO (Scala).Haskell, F#, Erlang, ML, Scheme, Lisp
LogicAnswer a question by searching for a solutionchecking the validity of a relationship among data, using axioms, rules, matching and logical inference. Inspired by first-order logic and theorem proving, and very effective in selected (AI-related) cases.Prolog, Datalog

Declarative languages (functional and logic) say what, not how: FP hides the evaluation order, logic programming the search strategy, and code gets much more concise (lessons 2–4, lesson 9).

Multi-paradigm languages use different paradigms for different parts of a system, historically at the price of complexity. Java and C# have basic FP (lambdas, streams); Scala is a full OO language with an advanced type system and first-class functions; tuProlog integrates Prolog with Java (lesson 11).

DSLs, tailored to one domain and often embedded in a mainstream language, raise the level of abstraction when an API becomes a language: “inside every system there’s a small language struggling to get out” (configuration and game description languages, shell scripts).

Concurrency fits no paradigm well and threads are too low-level; the answers are FP-inspired libraries and abstractions such as actors and streams, which in SSS belong to PCD.

9. How do you test a component that depends on others?

Testing: what, why and who

Testing is an investigation that informs stakeholders about quality, the execution of a system under specified conditions followed by an evaluation, and a whole life-cycle process of planning, preparing and evaluating. A test strategy answers why, what (the system under test), who, when (before, after or together with the code) and how.

From the slides: the five “schools” of testing

The levels are unit, integration, system (functional requirements black-box, non-functional ones such as efficiency and security), stress, and acceptance. In ISTQB vocabulary, you run a suite of test cases, using tools and oracles, to verify test conditions on testable test items (the SUT).

From the slides: the ISTQB vocabulary
TermMeaning
Test item, SUTthe software item to be tested (system under test)
Test conditiona testable aspect of a component, such as a feature
Testabilitythe degree to which test conditions can be set up and tests run to check them
Test casepreconditions, inputs, actions, expected results and postconditions, derived from test conditions
Test oraclethe source of the expected result to compare with the SUT’s (the “oracle problem”)
Test suite, test runa collection of test cases; one execution of a suite on a version of the SUT
Test toolhardware or software that supports a test activity

Test doubles

A unit test targets one component in isolation; if the component depends on others, a failure would be about the collaboration. So the depended-on component (DOC) is replaced by a test double (TD): a simple, predictable implementation set up just for the test, like a stunt double in a film. It must be correct “by construction”, or much simpler to test than the real DOC.

Test SUT real DOC Test double system under test stub, fake, spy, mock 2. exercise 3. calls 1. set up (inject) · 4. verify calls
The test replaces the DOC with a double during fixture set-up. The SUT cannot tell the difference. Stubs only answer; spies and mocks also record the calls, so the test can check how the SUT used its dependency.

Doubles are needed when the DOC cannot be used in a test (unavailable, slow, costly, with side effects, or breaking automation: GUIs, randomness, databases, files, network, untested code) and when the test needs visibility into how the SUT uses it (does it call it properly? does it handle its errors?).

FamilyDoubleReplaces the DOC with…
Stubs: emulate the replies the DOC gives to certain callsDummyan unused reference, an “empty object” that only fills a parameter
Stuba predefined responder, configured method by method
Fakea lightweight version that behaves like the real one (for instance, a sequence of results)
Mocks: emulate and examine the outgoing calls from the SUT to the DOCSpya proxy around a real DOC that records the calls it receives
Mockan object used to check that the SUT uses the DOC correctly overall

Mockito

Mockito creates doubles on the fly (the slides give "org.mockito" % "mockito-core" % "3.+" % Test; the seminar repository uses 5.23.0):

The running example is a device that asks a FailingChecker strategy whether switching on succeeds:

public interface Device {
  void on() throws IllegalStateException;
  void off();
  boolean isOn();
  void reset();
}

public interface FailingChecker {
  boolean attemptOn();
  void reset();
  String checkerName();
}

public class StandardDevice implements Device {
  private FailingChecker failingChecker;
  private boolean on = false;

  public StandardDevice(FailingChecker failingChecker) {
    this.failingChecker = Objects.requireNonNull(failingChecker);
  }
  @Override public void on() throws IllegalStateException {
    if (!this.failingChecker.attemptOn()) {
      throw new IllegalStateException();
    }
    this.on = true;
  }
  @Override public void off() { this.on = false; }
  @Override public boolean isOn() { return this.on; }
  @Override public void reset() {
    this.off();
    this.failingChecker.reset();
  }
  @Override public String toString() {
    return "StandardDevice{" + "policy=" + failingChecker.checkerName() + ", on=" + on + '}';
  }
}

public class RandomChecker implements FailingChecker {   // the real DOC
  private final Random random = new Random();
  private boolean failed = false;

  @Override public boolean attemptOn() {
    this.failed = this.failed || random.nextBoolean();   // once failed, always failed
    return !this.failed;
  }
  @Override public void reset() { this.failed = false; }
  @Override public String checkerName() { return "random"; }
}

The real checker is random, which is why StandardDevice needs a double. The seminar’s test class shows one kind per @Nested group:

public class StandardDeviceTest {

  private Device device;

  @Test
  @DisplayName("Device must specify a strategy")
  void testNonNullStrategy() {
    assertThrows(NullPointerException.class, () -> new StandardDevice(null));
  }

  @Nested
  class ShowcaseDummies {
    @Test
    @DisplayName("Device is initially off")
    void testInitiallyOff() {
      // an unused reference, a sort of "empty implementation"
      FailingChecker dummyFailingChecker = mock(FailingChecker.class);
      device = new StandardDevice(dummyFailingChecker);
      // checking that a device is on will not affect the strategy
      assertFalse(device.isOn());
    }
  }
  // ... the other @Nested groups follow
}
@Nested
class ShowcaseStubs {
  private FailingChecker stubFailingChecker;

  @BeforeEach
  void init() {
    this.stubFailingChecker = mock(FailingChecker.class);
    device = new StandardDevice(this.stubFailingChecker);
  }

  @Test
  @DisplayName("Device can be switched on")
  void testCanBeSwitchedOn() {
    // stubbing the test double, indicating "default behaviour"
    when(this.stubFailingChecker.attemptOn()).thenReturn(true);
    device.on();
    assertTrue(device.isOn());
  }

  @Test
  @DisplayName("Device won't switch on if failing")
  void testWontSwitchOn() {
    // multiple stubbing
    when(this.stubFailingChecker.attemptOn()).thenReturn(false);
    when(this.stubFailingChecker.checkerName()).thenReturn("mock");
    assertThrows(IllegalStateException.class, () -> device.on());
    assertEquals("StandardDevice{policy=mock, on=false}", device.toString());
  }
}
@Nested
class ShowcaseFakes {
  private FailingChecker fakeFailingChecker;

  @BeforeEach
  void init() {
    this.fakeFailingChecker = mock(FailingChecker.class);
    device = new StandardDevice(this.fakeFailingChecker);
    // faking is more than stubbing: this object pretends to be the real one
    when(this.fakeFailingChecker.attemptOn()).thenReturn(true, true, false);
    when(this.fakeFailingChecker.checkerName()).thenReturn("mock");
  }

  @Test
  @DisplayName("Device switch on and off until failing")
  void testSwitchesOnAndOff() {
    IntStream.range(0, 2).forEach(i -> {
      device.on();
      assertTrue(device.isOn());
      device.off();
      assertFalse(device.isOn());
    });
    assertThrows(IllegalStateException.class, () -> device.on());
  }
}
@Nested
class ShowcaseSpies {
  private FailingChecker spyFailingChecker;

  @BeforeEach
  void init() {
    // the spy is essentially a proxy to the DOC, used to capture events
    this.spyFailingChecker = spy(new RandomChecker());
    device = new StandardDevice(this.spyFailingChecker);
  }

  @Test
  @DisplayName("AttemptOn is called as expected")
  void testReset() {
    device.isOn();
    // no interactions with the spy yet
    verifyNoInteractions(this.spyFailingChecker);
    try {
      device.on();
    } catch (IllegalStateException e) {}
    // has attemptOn been called?
    verify(this.spyFailingChecker).attemptOn();
    device.reset();
    // attemptOn and reset: exactly two invocations recorded
    assertEquals(2,
        Mockito.mockingDetails(this.spyFailingChecker).getInvocations().size());
  }
}
@Nested
class ShowcaseMocks {
  private FailingChecker mockFailingChecker;

  @BeforeEach
  void init() {
    this.mockFailingChecker = spy(FailingChecker.class);
    device = new StandardDevice(this.mockFailingChecker);
    // the mock is a TD used to check you are collaborating as expected
    when(mockFailingChecker.attemptOn()).thenReturn(true, true, false);
    when(mockFailingChecker.checkerName()).thenReturn("mock");
  }

  @Test
  @DisplayName("attemptOn is called as expected")
  void testAttemptOn() {
    verify(this.mockFailingChecker, times(0)).attemptOn();
    device.on();
    verify(this.mockFailingChecker, times(1)).attemptOn();
    assertTrue(device.isOn());

    device.off();
    verify(this.mockFailingChecker, times(1)).attemptOn();   // off() does not ask the checker
    device.on();
    verify(this.mockFailingChecker, times(2)).attemptOn();
    assertTrue(device.isOn());

    device.off();
    verify(this.mockFailingChecker, times(2)).attemptOn();
    assertThrows(IllegalStateException.class, () -> device.on());   // third answer: false
    verify(this.mockFailingChecker, times(3)).attemptOn();
  }
}

The stub gives one fixed answer; the fake plays a believable sequence (true, true, false). The spy wraps a real RandomChecker, so the test ignores the random outcome and checks only the calls. The mock checks the protocol call by call: on() consults the checker, off() doesn’t. The course code creates it with spy(FailingChecker.class), which on an interface behaves like mock(FailingChecker.class); we checked that the test passes with either.

With annotated fields (@Mock, @Spy, initialised by MockitoAnnotations.openMocks(this)), a complete unit test reads:

public class DeviceUnitTest {

  private Device device;
  @Mock FailingChecker checker;

  @BeforeEach
  void init() {
    MockitoAnnotations.openMocks(this);
    this.device = new StandardDevice(checker);
  }

  @Test
  void strategyShouldNotBeNull() {
    assertThrows(NullPointerException.class, () -> new StandardDevice(null));
  }

  @Test
  void initiallyOff() {
    assertFalse(device.isOn());
    verifyNoInteractions(checker);
  }

  @Test
  void canBeSwitchedOn() {
    when(checker.attemptOn()).thenReturn(true);
    device.on();
    verify(checker).attemptOn();
    assertTrue(device.isOn());
  }

  @Test
  void switchOnMayFail() {
    when(checker.attemptOn()).thenReturn(false);
    assertThrows(IllegalStateException.class, () -> device.on());
    verify(checker).attemptOn();
  }
}

Integration tests

Unit tests check business logic well but cannot show that the system works as a whole. A unit test checks one unit of behaviour, quickly, in isolation; a test missing any of the three is an integration test.

An integration test is not a system test: it isolates the few collaborations needed for one multi-component behaviour, still mocking what cannot be used in testing. Once nothing is mocked and a whole part of the system runs, it is a system test. Integration tests must be automated too, and balanced against unit tests.

The example: a manager switches named devices on and reports starts and failures. The test uses the real manager and a real StandardDevice, mocking the checker and the reporter:

public interface DeviceManager {
  void start();
  void addDevice(String name, Device device);
  void switchOnAll();
  void switchOnOne(String name);
  boolean isSwitchedOn(String name);
}

public interface EventReporter {
  void reportStarted(String deviceName);
  void reportFailed(String deviceName, String reason);
}

public class Factory {
  static DeviceManager createDeviceManager(EventReporter reporter) {
    return new DeviceManager() {
      private Map<String, Device> map = new HashMap<>();

      @Override public void start() {
        this.map.forEach((name, device) -> reporter.reportStarted(name));
      }
      @Override public void addDevice(String name, Device device) {
        this.map.put(name, device);
      }
      @Override public void switchOnAll() {
        this.map.forEach((name, device) -> {
          try {
            device.on();
          } catch (IllegalStateException e) {
            reporter.reportFailed(name, "unknown");
          }
        });
      }
      @Override public void switchOnOne(String name) {
        try {
          this.map.get(name).on();
        } catch (IllegalStateException e) {
          reporter.reportFailed(name, "unknown");
        }
      }
      @Override public boolean isSwitchedOn(String name) {
        return this.map.get(name).isOn();
      }
    };
  }
}
public class IntegrationTest {

  private DeviceManager deviceManager;
  private Device device;
  @Mock FailingChecker checker;
  @Mock EventReporter reporter;

  @BeforeEach
  void init() {
    MockitoAnnotations.openMocks(this);
    this.deviceManager = Factory.createDeviceManager(reporter);
    this.device = spy(new StandardDevice(checker));        // real device, observed
    this.deviceManager.addDevice("dev-0", this.device);
  }

  @Test
  void managerCorrectlyReportsADeviceFailure() {
    when(this.checker.attemptOn()).thenReturn(false);
    this.deviceManager.start();
    this.deviceManager.switchOnAll();
    assertFalse(this.deviceManager.isSwitchedOn("dev-0"));

    verify(this.device).on();
    verify(this.checker).attemptOn();
    verify(this.reporter).reportStarted("dev-0");
    verify(this.reporter).reportFailed(eq("dev-0"), anyString());
  }
}

The test has two act steps and checks the whole chain: manager → device → checker → reporter. eq and anyString are argument matchers. All 13 tests of the seminar repository pass. Good practice: explicit domain boundaries, few layers, no circular dependencies, several act sections when needed; writing integration tests early supports design for testability.

10. How do you keep your work under version control?

A version control system manages the history of a repository of files. In a centralised VCS developers hold working copies of one remote repository; in a distributed VCS such as Git, everyone holds the entire repository.

Git stores snapshots rather than per-file changes (with deltas underneath). Its vocabulary:

workingtree staging area(index) local repo(.git) remote(origin) add commit push fetch checkout (switch branch or commit) pull = fetch + merge into the current branch
Where the main Git commands move changes. status and diff compare these places; merge joins another branch into the current one.
CommandEffect
init / clonecreate a new repository / download a project with its whole history
statusshow untracked, modified and staged files
addstage changes into the index (also marks merge conflicts as resolved)
diffdifferences between a commit and the working tree, or between the working tree and the index
commitrecord the staged snapshot in the current branch
branch / checkoutlist, create, delete branches / switch the working tree to a branch or commit
mergeincorporate another branch into the current one
fetch / pulldownload objects and references from another repository / fetch and integrate into the current branch
pushupdate remote references with the local commits

A simple workflow. Clone, pick a branch, change files, stage, commit; merge into the main branch (resolve conflicts, add, commit); push. If someone pushed first, pull, integrate and push again. In the lab:

$ git config --global user.name "Name"          # once: introduce yourself to git
$ git config --global user.email [email protected]

$ git clone [email protected]:unibo-pps/pps-lab01.git   # first time
$ git pull origin                                     # afterwards (origin: the default remote)

$ git add .                                           # stage your changes
$ git commit -m "Make canBeSwitchedOn pass"           # commit, e.g. at the end of a TDD step
$ git push

Forks. To push to your own remote copy, fork the course repository (the host’s fork button also supports pull requests), clone the fork, and add the original as a second remote:

$ git remote add sync <originalRepo>               # the course's repository
$ git pull sync master                              # get its updates
$ git branch originalMaster --track sync/master     # a local branch tracking the original
$ git push -u origin master                         # push to your fork and track it

sync then brings the teachers’ updates and origin is your fork. We replayed this on local repositories: git branch -vv shows master tracking origin/master and originalMaster tracking sync/master.

The environment: Git, JDK 21 (Adoptium), IntelliJ IDEA 2025.2 (Professional is free for students) with the Scala plugin, Scala 3.3. In IntelliJ a project (settings in .idea) contains modules that build and run separately, and the SDK is set in File → Project Structure. Never commit IDE files (.idea, .iml): builds rely on SBT, Maven or Gradle.

Check your understanding

What is the difference between internal and external quality, and why does the course insist on the internal one?

External quality is what stakeholders see: correctness, usability, efficiency, reliability. Internal quality is what lets the team keep going: maintainability, readability, testability. The second is the means to the first. Aiming at external quality while neglecting internal quality is a common cause of failure.

What is technical debt? Point it out in the Configuration example.

The extra future work caused by choosing a quick, sub-optimal solution now. In Configuration, the exception is only printed: a missing file leaves data null, and the failure appears later as a NullPointerException in getData().

Rigidity, fragility, immobility, viscosity: tell them apart.

Rigid: hard to change, because changes cascade. Fragile: easy to break, because a change here causes errors elsewhere. Immobile: hard to reuse, because extracting the code costs too much. Viscous: doing things properly costs more than hacking, often because of the tools or the environment.

Give an example of anticipation of change and one of generality. How do they differ?

AOC: replace a magic number with a constant, or hide object creation behind a factory, to be ready for a likely change. GEN: turn an implementation choice into a parameter, e.g. filteredDevices(Predicate) instead of devicesByName, to solve a more general problem. AOC prepares for a change; GEN widens the problem being solved.

Which SOLID principle does EternalTV violate, and how does AbstractConsole protect against the same kind of problem?

LSP. EternalTV type-checks as a Device but ignores off(), so a controller’s setAllOff() no longer switches everything off. AbstractConsole makes println final and defines it as print followed by a line feed, so no subclass can break that relation.

State the three laws of TDD. Why must the refactoring step leave all tests green?

No production code without a failing test; no more test than is needed to fail (not compiling counts as failing); no more code than is needed to pass. Refactoring must not change behaviour, and green tests are the evidence that it didn’t. The logger’s OCP step, which changed the output, shows what happens without such a test.

Strategy and Template Method solve the same problem. What is the difference?

Strategy uses delegation: the varying algorithm is an object passed in, chosen at run time (e.g. a fee function). Template Method uses inheritance: the varying step is an abstract method implemented by a subclass, fixed at compile time (e.g. feeCalculation in SilverBankAccount).

Stub, fake, spy, mock: what does each one do in the StandardDevice tests?

The stub answers attemptOn() with a fixed value. The fake answers with a realistic sequence (true, true, false). The spy wraps a real RandomChecker and records its calls. The mock checks the collaboration, i.e. exactly how many times attemptOn() was called after each operation. Stubs emulate replies; spies and mocks examine outgoing calls.

When does a test stop being a unit test? And an integration test?

A unit test checks one unit of behaviour, quickly and in isolation; if any of the three fails, it is an integration test. When nothing is isolated or mocked any more and a whole part of the system runs, it has become a system test.

What is the staging area, and what does git pull do that git fetch doesn’t?

The staging area (index) holds the snapshot that the next commit will record; git add fills it. fetch only downloads remote commits and references, while pull also integrates them into the current branch (fetch plus merge).

Where this lesson comes from

Decks: 00-intro (course organisation and exam) · 00-lab (Git, IntelliJ set-up) · 01-quality (all parts except “Software methodologies and the agile manifesto”, which is in lesson 8) · 01-lab (TDD guidelines, JUnit 5, exercises) · 01b-lab (TDD on legacy code) · Seminar-AdvancedTesting (testing basics, test doubles, Mockito, integration testing). Code: pps-code-java packages u01.smell, u01.clean, u01.oop, u01.solid, u01.devices, u01.patterns.*, u01.banks.*; tdd-solid-bankaccounts; seminar-testing. The Java examples were compiled and run with JDK 21; the JUnit tests (39 in pps-code-java and tdd-solid-bankaccounts, 13 in seminar-testing) ran with JUnit 5.10 and Mockito 5.23; the ScalaTest spec ran with Scala 3.3.6 and ScalaTest 3.2.19.