Module 1: Functional Requirements
Module Overview
In this module, you'll learn how to create effective functional requirements for your Java applications. You'll understand how to identify and document requirements that are testable, measurable, and implementable.
Learning Objectives
- Construct clear and testable use cases considering potential preconditions, invariants, and postconditions
- Produce use cases that cover the happy and alternate cases of a software system
- Write a set of tests to verify that a software system implements provided use cases
- Execute tests manually to verify that a software system implements provided use cases
- Contrast functional and non-functional requirements
- Define preconditions, invariants, and postconditions
- Define what happy and alternative cases are
- Identify the actor in a provided use case
- Implement a method that sets up preconditions, performs the steps that the "actor" does, and checks postconditions and invariants
- Convert use cases to a test plan document that describes the tests to be produced
- Identify alternative cases in a set of use cases
- Identify code outside the scope that is being tested
- Recall that the actor of a use case is the entity that provides or receives information
Key Topics
Introduction to Functional Requirements
Learn the basics of functional requirements and why they're important for software development.
Understanding Functional Requirements
Functional requirements describe functions the software must perform. They define what the system should do, such as:
- The order number retriever function shall throw an Exception when the provided order number is invalid
- The ATM shall log a user in when they provide a valid card and matching PIN
- The game shall merge two blocks with the same value when the player swipes them together
Difference Between Functional and Non-Functional Requirements
While functional requirements define what a system should do, non-functional requirements describe how the system should perform:
- The program shall require no more than 6GB of memory
- The service shall retrieve the orders for an order ID in less than 500ms
- The online chat shall display messages from 1000 simultaneous users
Example Java Implementation
public class OrderProcessor {
/**
* Processes an order based on functional requirements
* @param orderId The unique identifier for the order
* @throws IllegalArgumentException if the orderId is invalid
* @return The processed order details
*/
public Order processOrder(String orderId) {
// Check precondition - valid orderId
if (orderId == null || orderId.isEmpty()) {
throw new IllegalArgumentException("OrderId cannot be null or empty");
}
// Process the order
Order order = retrieveOrder(orderId);
// Check postcondition - order must be valid
if (order == null) {
throw new IllegalStateException("Could not retrieve a valid order");
}
return order;
}
}
Requirements and Use Cases
Overview
At Amazon, and in general software development, a Quality Assurance Engineer (QAE) works with developers to ensure that our products are high quality. "Quality" describes how well something does its job. Therefore, to determine if our software is high quality, we have to describe its job.
Usually, our customers give us requirements, telling us what they want our software to do. We refine these into simple stories about how the customer will use our software. Each story is a use case.
Use cases help us understand and improve our requirements. Once we have finalized our use cases, we use them to determine whether our software does its job: whether it is high quality.
Types of Requirements
Requirements come in two overall categories: functional and non-functional.
Functional Requirements
Functional requirements describe functions the software must perform. For example:
- The order number retriever function shall throw an Exception when the provided order number is invalid.
- The ATM shall log a user in when they provide a valid card and matching PIN.
- The game shall merge two blocks with the same value when the player swipes them together.
Non-functional Requirements
Non-functional requirements describe the software's performance characteristics. For example:
- The program shall require no more than 6GB of memory.
- The service shall retrieve the orders for an order ID in less than 500ms.
- The online chat shall display messages from 1000 simultaneous users.
SDEs and QAEs work together to write and perform tests that prove our software does what the customer expects. Although their areas of responsibility are not cleanly separated, SDEs generally spend most of their effort on functional requirements and tests, and QAEs handle most non-functional requirements.
Writing a Use Case
Let's consider an example. As part of its planning, the Prime Air drone delivery service needs to find a good route to fulfill a list of orders starting from the warehouse. The drone is expensive to fly, so they've made this request to your team:
Provide a service that will return the shortest route to deliver a list of orders starting and ending at a given warehouse.
A senior scientist on your team has developed an algorithm that will do the job. You are writing the use cases.
A use case may be a story about how the customer will use our software, but applying a little structure makes it more useful and ensures we're thinking out the scenarios thoroughly.
Parts of a Use Case:
- Unique name: short description.
- Preconditions: conditions that must be true before we can apply this use case.
- Invariants: conditions that do not change at all when this use case is applied.
- Postconditions: conditions that are true after this use case is applied.
- Steps: the actions taken during the use case.
Naming a Use Case
Use case names often follow an "actor verb noun" pattern, like "Player Places Bid" or "Customer Adds Item to Cart". If the actor is very general or is known ahead of time, it can be left out. In our case, we might use "Requests Shortest Route From Warehouse" since we know the actor right now is Prime Air, but the function could be used by any fulfillment vehicle in general.
Components of a Use Case
- Preconditions: List the assumptions we make about the use case. For this case, "Starting warehouse ID is not null" would be a good precondition.
- Invariants: List the promises you can make about what won't change when the use case is performed. We could promise, "Input will not be modified".
- Postconditions: List the promises you can make about what will change by the time the use case is done. We could promise, "Result will be the order in which the drone should deliver items such that it travels the shortest distance."
Steps in a Use Case
List the steps that the actor and your software will take. These are most useful if they follow one of these templates:
- The actor provides or requests information.
- The software provides or requests information.
- The software does some work.
Notice that none of the steps includes the word "if". Instead of using "if" in a step, create two use cases with preconditions for each of the "if" conditions. For example, consider a toggling power button: the first draft of use cases might include "User Toggles Power", with a step like "If power is on, the device turns off; otherwise device turns on." Instead, write two use cases: "User Toggles Power While Device On" with precondition "Device is on", and "User Toggles Power While Device Off", with precondition "Device is off".
If you have more than 10 steps, consider breaking the use case into smaller ones that describe smaller portions of the overall flow: "Customer Makes Withdrawal from ATM" might need to be broken into "Customer Logs in to ATM" and "Validated Customer Makes Withdrawal".
UI Description vs General Description
The steps shouldn't describe checkboxes, text inputs, or any other UI elements. The UI can change unexpectedly, and we should pay more attention to what gets done than how it gets done. That's usually a lot shorter, too. Consider:
Complicated UI Description
- The customer enters a routing number into the "routing number" text box.
- The customer enters an account number into the "account number" text box.
- The customer clicks the "Select Payment Date" button.
- The system shows a calendar with available payment dates highlighted.
- The customer clicks a calendar day.
- The customer clicks the "Add Automatic Payment" button.
Shorter, General Description
- The customer enters the account information.
- The system displays available payment dates.
- The customer selects a payment date.
Example Use Cases
First, the happy case:
Use Case: Requests Shortest Route from Non-null Warehouse ID
Preconditions:
- The input is a non-null warehouse ID and a list of orders.
Invariants:
- The input will not be modified.
Postconditions:
- The result will be a sorted list of orders that provides the shortest delivery route.
Steps:
1. The caller invokes the shortest route function with an existing warehouse ID and a list of orders.
2. The algorithm calculates the shortest delivery route and returns the sorted list of orders.
And here's an alternate case:
Use Case: Requests Shortest Route from Null Warehouse ID
Preconditions:
- The input is a null warehouse ID and a list of orders.
Invariants:
- The input will not be modified.
Postconditions:
- The result is an exception.
Steps:
1. The caller calls the shortest route function with a null warehouse ID and a list of orders.
2. The function raises an IllegalArgumentException.
Non-Functional Requirements and Use Cases
While those two use cases cover a functional requirement (getting a sorted list of orders for the shortest route), they don't cover a non-functional requirement (such as calculating the shortest route in less than 500 ms).
You can use the same structure for a non-functional use case. Non-functional use cases often need to be tested with external tools, like system monitors and load testers.
QAEs specialize in tracking and testing non-functional requirements. For now, we're going to focus on functional requirements and the SDE's role in writing and refining them.
Refining the Requirements
Complex software can have many requirements, making it easy to forget or misunderstand important functionality. You can find missed functionality by examining each step of your use cases and asking, "what could go wrong here?" Make a new use case with preconditions for every answer.
Once you're done, determine which requirement each use case helps satisfy. Some requirements will have multiple use cases. Requirements without use cases indicate that you either don't understand how the customer will use your software or need to write another use case.
On the other hand, use cases without requirements indicate that you either have found a hidden requirement or don't need to write that code.
Testing Functional Use Cases
Comprehensive use cases describe what our software needs to do. That allows us to write code that delights our customers. We can test that our software does what it should do by following the conditions and steps in the use cases.
Every time we change the code, even to improve it, we risk breaking it. Testing by following the use cases lets us verify that the software still does what it's supposed to do. Therefore we want to test our use cases every time we change the code. (Note that use cases can also change; we also want to change our code and test our use cases when that happens.)
Automated testing frameworks like JUnit allow us to run tests without human intervention. We'll learn more about automated testing later; for now, we will learn about manually testing your use cases.
Example Use Cases
Let's use the example "shortest route from warehouse" use cases that we saw in the previous reading.
First, the happy case:
Use Case: Requests Shortest Route from Non-null Warehouse ID
Preconditions:
- The input is a non-null warehouse ID and a list of orders.
Invariants:
- The input will not be modified.
Postconditions:
- The result will be a sorted list of orders that provides the shortest delivery route.
Steps:
1. The caller invokes the shortest route function with an existing warehouse ID and a list of orders.
2. The algorithm calculates the shortest delivery route and returns the sorted list of orders.
And here's the alternate case:
Use Case: Requests Shortest Route from Null Warehouse ID
Preconditions:
- The input is a null warehouse ID and a list of orders.
Invariants:
- The input will not be modified.
Postconditions:
- The result is an exception.
Steps:
1. The caller calls the shortest route function with a null warehouse ID and a list of orders.
2. The function raises an IllegalArgumentException.
Turning Use Cases into Code
Remember that use cases generally have limited kinds of steps:
- The actor provides or requests information.
- The software provides or requests information.
- The software does some work.
When we write the code, we turn "the software" steps in all the use cases into Java. We also write ways for "the actor" to provide and receive information. In our example, the actor is "the caller", which we expect to be a Java program. They will provide information by calling our function with an input value. They will receive information from the function return value.
With that in mind, we might write code for the use cases that looks like:
public class ShortestRouteFromWarehouse {
private ShortestRouteService shortestRouteService = new ShortestRouteService();
// Both cases: "Caller calls the function"
public List<Order> getShortestRouteFromWarehouse(String warehouseId, List<Order> orders) {
// Alternate case: "function raises exception"
if (warehouseId == null) {
throw new IllegalArgumentException(
"Can't calculate shortest route from null warehouseId!");
}
// Happy case: "algorithm calculates return ordering"
// The algorithm our scientist gave us is implemented in a separate class:
List<Order> shortestRoute = shortestRouteService.getShortestRoute(warehouseId, orders);
return shortestRoute;
}
}
Writing Tests
When we write the tests, we verify each use case individually. We write a Java method that sets up the preconditions (the "given" stage), performs the steps that "the actor" does (the "when" stage), and checks the postconditions and invariants (the "then" stage). We name the Java method following the pattern "methodBeingTested_testCase_expectedResult".
Before writing actual code, we often transpose the use cases into a "test plan", a document describing the tests we intend to write. These are a reviewable, intermediate step between requirements and verification. Test plans often expose shared functionality that we can write as helper methods, too. The test plan format is similar to the structure we use for the actual test code.
Here's a test plan for our example:
getShortestRouteFromWarehouse_fromNonNullWarehouseId_isShortestRoute
Happy case, providing the shortest route given a non-null warehouse ID and list of orders.
Given
A non-null warehouse ID and list of Orders
When
We call getShortestRouteFromWarehouse()
Then
The result is the actual shortest route.
The input is not modified
getShortestRouteFromWarehouse_fromNullWarehouseId_throwsException
Alternate case, throwing an exception on non-existent warehouse ID.
Given
A null warehouse ID
A list of Orders
When
We call getShortestRouteFromWarehouse()
Then
The result is an exception.
The input is not modified.
We could put the test code anywhere, but we usually put the tests in a separate test directory to distinguish between our software and our tests. It's most readable if we have a separate test class for each class we want to test, and we name it the same as the class we're testing with "Test" on the end. We'll explain why when we get to running the tests.
With that in mind, here's a class with methods to test our ShortestRouteFromWarehouse class's getShortestRouteFromWarehouse function:
public class ShortestRouteFromWarehouseTest {
// Happy case, providing the shortest route for an existent warehouse and list of Orders
public boolean getShortestRouteFromWarehouse_fromNonNullWarehouseId_isShortestRoute() {
// GIVEN
// A non-null warehouse ID
// We are giving a non-null test Warehouse ID and a list of Orders.
// We happen to know the the shortest route would be orderC -> orderB -> orderA
String exampleWarehouseId = "ABC1";
Order orderA = new Order("numberA", "123 Address Lane");
Order orderB = new Order("numberB", "234 Address Drive");
Order orderC = new Order("numberC", "345 Address Street");
List<Order> exampleOrders = new ArrayList<>();
exampleOrders.add(orderA);
exampleOrders.add(orderB);
exampleOrders.add(orderC);
// WHEN
// We call getShortestRouteFromWarehouse()
ShortestRouteFromWarehouse testClass = new ShortestRouteFromWarehouse();
List<Order> result = testClass.getShortestRouteFromWarehouse(exampleWarehouseId, exampleOrders);
// THEN
// The result is the correct length and ordering
if (result.size() != 3 ||
!result.get(0).equals(orderC) ||
!result.get(1).equals(orderB) ||
!result.get(2).equals(orderA)) {
System.out.println(
"Expected ordering to be C -> B -> A!");
return false;
}
// The input is not modified
if (exampleWarehouseId != "ABC1") {
System.out.println(
"Expected warehouseId to remain ABC1!");
return false;
}
// All conditions satisfied!
System.out.println(
"Use Case 'Requests Shortest Route from non-null Warehouse ID' passes!");
return true;
}
// Alternate case, throwing an exception on invalid input
public boolean getShortestRouteFromWarehouse_fromNullWarehouseId_throwsException() {
// GIVEN
// A null warehouse ID and a list of Orders
String exampleWarehouseId = null;
Order orderA = new Order("numberA", "123 Address Lane");
Order orderB = new Order("numberB", "234 Address Drive");
Order orderC = new Order("numberC", "345 Address Street");
List<Order> exampleOrders = new ArrayList<>();
exampleOrders.add(orderA);
exampleOrders.add(orderB);
exampleOrders.add(orderC);
// WHEN
// We call getShortestRouteFromWarehouse()
ShortestRouteFromWarehouse testClass = new ShortestRouteFromWarehouse();
List<Order> result = testClass.getShortestRouteFromWarehouse(exampleWarehouseId, exampleOrders);
// THEN
// The result is an exception
System.out.println(
"Expected shortest path from null Warehouse ID to raise IllegalArgumentException!");
// The input is not modified
if (exampleWarehouseId != "ABC1") {
System.out.println(
"Expected warehouseId to remain ABC1!");
}
// An exception should have stopped us from getting here
return false;
}
}
It is possible to verify that a method throws an exception, but we'll learn about that in a different lesson.
Running Tests
BlueJ has a context menu for creating an object of any class and running one of its methods, but IntelliJ and most other IDEs don't. Java doesn't have a way to create objects from a terminal, either. Instead, we must write a main method that calls the methods we want. This allows us to call the method from either the terminal or from almost any IDE.
We will write a main in each test class that runs that class's tests, although, in regular development, we won't write any main at all. (Usually, that detail is hidden from us by the server or testing framework.) Here's an example of the method we can add to the ShortestRouteFromWarehouse class:
// Magic invocation to test all the use cases in this class
public static void main(String[] args) {
ShortestRouteFromWarehouseTest test = new ShortestRouteFromWarehouseTest();
// Test the happy case
if (test.getShortestRouteFromWarehouse_fromNonNullWarehouseId_isShortestRoute()) {
System.out.println(
"Test getShortestRouteFromWarehouse_fromNonNullWarehouseId_isShortestRoute passes!");
} else {
System.out.println(
"Test getShortestRouteFromWarehouse_fromNonNullWarehouseId_isShortestRoute fails!");
}
// Test the alternate case
System.out.println(
"Test getShortestRouteFromWarehouse_fromNullWarehouseId_throwsException should throw an exception...");
test.getShortestRouteFromWarehouse_fromNullWarehouseId_throwsException();
System.out.println("Test getShortestRouteFromWarehouse_fromNullWarehouseId_throwsException fails!");
}
You can run this test from IntelliJ by clicking the green "run" triangle next to it. In the terminal, you could navigate to the directory with the compiled classes and type java ShortestRouteFromWarehouseTest. Note how adding "Test" to the end of the class being tested tells you exactly what you're doing.
On the Job
Amazon expects SDEs to write tests for all their use cases and run them any time the code changes. We never release code that hasn't been tested. There are automated tools that help us do this, and we'll cover them in later lessons.
Frameworks usually provide the main method in actual development, and SDEs don't write main methods. When we do, we usually write one main for the entire service.
Real-world examples usually have many use cases. You can expect to work on a smaller portion of systems so large that they become difficult to track. We often use spreadsheets or documents to associate requirements, use cases, and test methods. These help you be sure that your software does what it's supposed to do.
The naming conventions we introduced here vary by team. We'll detail what BloomTech expects in later lessons, but these examples are a good introduction.