Notice
Recent Posts
Recent Comments
Link
반응형
Tags more
Archives
관리 메뉴

Daily stories on Tech • Beauty • Travel

Testing 본문

Tech/Java Web Development

Testing

nandarasol 2025. 5. 12. 09:59
반응형

Testing With JUnit and Selenium

The figure above shows the test-driven development lifecycle. First, there is an idea for a new feature. That feature idea is turned into a series of user stories, which are then turned into tests, which fail because the feature does not exist yet. The developers then work on the feature’s code requirements until all of the tests pass, and the cycle begins anew.

Testing is an important and highly desired part of the software development process. In a world where uptime and user retention means everything, it’s important to validate that your application actually does what it’s supposed to before it goes into production.

The standard accepted way to enforce this is by adopting a test-driven development lifecycle, or TDD. In this model, the “red then green” philosophy is dominant — tests should be written before the feature to be tested, meaning that they start off failing — aka, the tests are “red.” Then, as the feature is implemented, one test after another should start to pass — aka, become “green.”

To facilitate this approach, it’s useful to have a standard way to describe features or requirements to be tested. For this, we turn to the concept of a “user story.” A user story describes the functionality a feature should have from the perspective of a user interacting with the application. Typically, the format of a user story is:

As a user, I can take some action in order to achieve some goal.

Often a feature will be broken up into many user stories, each of which should correspond to at least one test to be implemented for that feature. If all the tests pass, it means that all of the user stories are successfully implemented, and the feature is complete.

Key Terms

  • Test Driven Development: a software development methodology that emphasizes writing tests before the code to be tested. This gives developers a roadmap to success — once all the tests are passing, the feature is complete!
  • User Story: User stories are short sentences derived from feature requirements in the format of As a user, I can in order to . These are used to create tests to verify the components of a feature.

Testing

There are many different types of tests meant to validate different types of features and different layers of an application. In this course, we’re going to focus on two specific types of tests: Unit tests and integration tests.

Unit tests are meant to test a single unit or component of an application or process — these tests should be simple, and verify that a specific method, component, or process step acts as expected according to its inputs. Sometimes you’ll also use unit tests to verify that the unit under test fails predictably, as well; it’s good to test both positive and negative conditions in a unit test!

Integration tests are the next layer up from unit tests. Instead of testing a single unit of an application, they test multiple units and how they integrate with one another. Often, an integration test will validate an entire user story, for example, while a unit test will validate a single step in the process a user story describes.

The rule of thumb is that unit tests should be used to test invariants — conditions that do not change — and integration tests should be used to test user actions and entire process flows .

Key Terms

  • Unit Tests: A unit test only validates the smallest unit of a computational process. That might mean a test of a single method, or a single component in an application.
  • Invariants: An invariant is a law of computation, something that shouldn’t change despite changing circumstances. For example, adding 0 to a number should always result in the original number, and dividing by 0 should always result in an error.
  • Integration Tests: Integration tests are intended to validate the operation of multiple application components as they interact with each other — or integrate with one another.

Testing with JUnit

JUnit is the standard Java testing framework, and despite its name, it is capable of much more than unit tests. JUnit expects all tests for an application to be collected in class files, just like any other Java code.

Annotations

JUnit provides an annotation, @Test, that can be placed on a method in a test class to declare a single test. Each method annotated like this can be either executed individually, or in a group - and in both cases, JUnit will generate a report that lists each test that was run, and whether it was successful or not.

In order for JUnit to know if a test is successful or not we need to use assertions. @Test-annotated methods should not have a return value! Instead, we can use special methods provided by JUnit to check our assumptions about the code under test. We'll look at a concrete example of this in the next video. To begin with, see the list of all annotations here.

Sometimes, we need to initialize some data or objects to be used in our test methods. JUnit provides a few extra annotations to define this initialization code. @BeforeEach- and @AfterEach-annotated methods will be called before an after each @Test-annotated method, respectively, and @BeforeAll- and @AfterAll-annotated methods will be called at the before and after all tests have been executed, respectively.

Assertions

An assertion, in the context of JUnit, is a method we can call to check our assumptions about the behavior of the unit under test. If our assumptions are correct, the assertion silently returns and the test method continues. If they’re false, the assertion throws a special exception class that JUnit uses to build the final failure report, and the test method halts execution.

Assertions are the static methods defined in the Assertion class. See the list of assertions here , and an example on how to use assertions in a unit test here.

For example, assertEquals(int expected, int actual) is an assertion method you can call in your test method to assert that the actual and expected integer values are equal. Let's see an example implementation of annotations and assertions next.

Testing with JUnit — Annotations and Assertions

https://github.com/udacity/nd035-c1-spring-boot-basics-examples/tree/master/udacity-jwdnd-c1-l4-testing-with-junit-master

In the previous video example, we looked at some basic JUnit tests to learn more about JUnit’s annotations and assertions. Some key takeaways:

  • In a Maven project, it’s extremely important to make sure your JUnit test classes are in the right directory. Maven expects tests to be in the src/test/java directory. Always double check!
  • JUnit’s assertions are all static methods on the org.junit.jupiter.api.Assertions class, so to use them you need to statically import the methods you need
  • The most commonly-used assertion is assertEquals, which can be used to check if the result of some action is equal to the expected result.
  • Another common assertion is assertThrows, which is used to check if a given piece of code does throw an exception as expected. This can be useful to check so-called negative test cases, where we want to make sure our application fails in the correct way. This assertion uses Java 8's lambda expression syntax to capture a piece of code to test - if you're not familiar with this syntax, you can find more information about it in the further research section below.
  • @BeforeEach-annotated methods are particularly useful for initializing some data that needs to be in the same state for every test. For example in the video, we used this to ensure that a list under test always has the same values at the beginning of each test.

The Lifecycle of a JUnit Test Class

The diagram above shows the lifecycle of a JUnit test class.

  • First, JUnit instantiates the class and calls any method annotated with @BeforeAll.
  • Then it chooses a test to run. It calls any method with the @BeforeEach method, then it calls the @Test-annotated test method.
  • Finally it calls the @AfterEach-annotated method. It repeats this for each @Test-annotated method in the class.
  • When none remain, it calls the @AfterAll-annotated method and destroys the test class instance.

Further Research

Testing with JUnit

There are lots of different ways to solve this problem, but let’s look at a simple one.

public String fizzBuzz(int number) {
        } if (number % 3 == 0) {
            return "Fizz";
        } else if (number % 5 == 0) {
            return "Buzz";
        } else {
            return "" + number;
        }
    }

This solution passes our first three blocks of tests, but fails on the check for divisible by 3 and 5. That part can be passed by checking for divisible by both 3 and 5 (or checking for divisible by 15) first:

public String fizzBuzz(int number) {
        if (number % 3 == 0 && number % 5 == 0) {
            return "FizzBuzz";
        } else if (number % 3 == 0) {
            return "Fizz";
        } else if (number % 5 == 0) {
            return "Buzz";
        } else {
            return "" + number;
        }
    }

It still fails the final part of the unit test, however. assertThrows expects that an IllegalArgumentException is thrown in the event that we try to pass in a 0 or -1. Let's try adding the negative number check before returning:

public String fizzBuzz(int number) {
        if (number % 3 == 0 && number % 5 == 0) {
            return "FizzBuzz";
        } else if (number % 3 == 0) {
            return "Fizz";
        } else if (number % 5 == 0) {
            return "Buzz";
        } else if (number < 1) {
            throw new IllegalArgumentException("Value must be greater than 0");
        } else {
            return "" + number;
        }
    }

This almost works, but it turns out that 0 mod 3 = 0, and so passing 0 in returns FizzBuzz instead of throwing our exception. The check must be at the top to the top to pass all our unit tests:

public String fizzBuzz(int number) {
        if (number < 1) {
            throw new IllegalArgumentException("Value must be greater than 0");
        } else if (number % 3 == 0 && number % 5 == 0) {
            return "FizzBuzz";
        } else if (number % 3 == 0) {
            return "Fizz";
        } else if (number % 5 == 0) {
            return "Buzz";
        } else {
            return "" + number;
        }
    }

These unit tests are pretty thorough, but they are not perfect. Consider the following example:

public String fizzBuzz(int number) {
        if (number == 0 || number == -1) {
            throw new IllegalArgumentException("Value must be greater than 0");
        } else if (number == 15 || number == 75) {
            return "FizzBuzz";
        } else if (number % 3 == 0) {
            return "Fizz";
        } else if (number % 5 == 0) {
            return "Buzz";
        } else {
            return "" + number;
        }
    }

This ALSO passes all our unit tests, despite obviously failing to perform correctly for the values -2 or 30.

It’s not always practical to test every possible input and output, and so the main goal of our unit tests is to test a good selection of reasonable values, and some typical boundary cases. We could run a loop in this test and look for hundreds of values, but at a certain point you’re just reimplementing the program inside the unit test and it’s not worth it. Go for the biggest bang for your buck and rely on integration testing to deal with the occasional outliers!

JUnit in Situ

When we write tests, it’s with the intention to run them and report on the results. Test runners like JUnit provide many ways to report the results of a test run, but one of the most useful ways to interact with that reporting is through an IDE, like IntelliJ.

There are three main advantages to running JUnit tests from an IDE:

  • Interactive Reporting: When we run tests in an IDE, we can usually inspect the results of each test individually. If an assertion fails or an unexpected exception is triggered, the stack trace and circumstances will be shown in the details for each test, and clickable links in the results help you navigate to problem areas in your code.
  • Interactive Debugging: When a pernicious problem persists, it can often be helpful to step through the code’s execution line-by-line to inspect both the control flow and the values in memory used by the program. This is called debugging, and while it’s technically possible to do outside of an IDE, IDEs like IntelliJ provide many useful tools for making the process as painless as possible.
  • Code Coverage Reports: When we run code in an IDE like IntelliJ, we can choose to have the IDE track which lines of our code were visited, and how many times. This can be wildly useful when trying to track down why a branch of a condition isn’t being reached, as well as when determining how much the entire code base is covered by the currently-implemented tests.

In the next video, we’ll take a look at some of these features in IntelliJ while exploring a real-world scenario — fixing failing tests.

https://github.com/udacity/nd035-c1-spring-boot-basics-examples/tree/master/udacity-jwdnd-c1-l5-junit-in-situ-master

In this first foray into fixing failing tests, we ran our tests within IntelliJ to get a report of the status of all tests. Initially, these were all failing, but by clicking through IntellliJ’s test report details, we quickly discovered a common problem to all of the tests: some of the data under test wasn’t being initialized at all! We solved this by adding an @BeforeAll-annotated method responsible for that initialization logic. Running the tests again, our report shows that some are slowly turning green - progress! All we needed was a handy overview of the test results, and we could quickly identify a common problem between them.

In the next video, we’ll explore another tool the IDE provides for this kind of work — debugging.

https://github.com/udacity/nd035-c1-spring-boot-basics-examples/tree/master/udacity-jwdnd-c1-l5-junit-in-situ-master

In this second attempt to fix our failing tests, we used IntelliJ’s debugger to check our code under test line-by-line. We found that the conditions our test was validating did not match the code under test — which means we had a decision to make.

Usually, in cases like this, where the test does not match the code it is testing, we have to decide which is correct. In a real-world development scenario, we would check both against the technical requirements provided to us, but since this is just an example, we chose to assume that the code under test was correct.

In any case, debugging helped us find an issue that otherwise might be hard to find. In the next video, we’ll try to get the remaining tests passing, and we’ll see how code coverage can help us determine if our code is being sufficiently tested.

https://github.com/udacity/nd035-c1-spring-boot-basics-examples/tree/master/udacity-jwdnd-c1-l5-junit-in-situ-master

In this final push to get our tests passing, we looked at the remaining failing tests. The first was another issue of test/code not being in sync: our test expected a specific exception to be thrown, but the code under test wasn’t throwing that exception! This is a good example of why assertThrows is useful in a testing context; if our feature requirements or documentation say that a method should throw an exception under certain circumstances, it can cause real problems if it does anything else.

Moving on to the remaining failing test, we saw that it was performing the exact same test as a previous successful test. This is usually a good sign that these tests rely on some data that needs to be initialized identically before each test. Indeed, we found that the data we initialized with an @BeforeAll-annotated method actually needed to be initialized before each test, not all of them, so we changed the @BeforeAll annotation to @BeforeEach.

Finally, to verify that our tests weren’t overlooking anything in our code base, we re-ran them with IntelliJ’s code coverage feature. This showed us that while our tests were covering nearly all of the lines of code in the project, there was one method we weren’t testing at all. After adding a test for that method and re-running the test suite with coverage, we saw our entire codebase lit up in green. Nice!

Key Terms

  • Interactive Reporting: When we run tests in an IDE, we can usually inspect the results of each test individually. If an assertion fails or an unexpected exception is triggered, the stack trace and circumstances will be shown in the details for each test, and clickable links in the results help you navigate to problem areas in your code.
  • Interactive Debugging: When a pernicious problem persists, it can often be helpful to step through the code’s execution line-by-line to inspect both the control flow and the values in memory used by the program. This is called debugging, and while it’s technically possible to do outside of an IDE, IDEs like IntelliJ provide many useful tools for making the process as painless as possible.
  • Code Coverage Reports: When we run code in an IDE like IntelliJ, we can choose to have the IDE track which lines of our code were visited, and how many times. This can be wildly useful when trying to track down why a branch of a condition isn’t being reached, as well as when determining how much the entire code base is covered by the currently-implemented tests.

Further Research

Solution: JUnit in Situ

There are quite a few holes in this implementation. Here’s one way to organize your tests to identify failures:

FizzBuzzServiceTest.java

@Test
void testBuzzFizz_happyPath() {
  FizzBuzzService fbs = new FizzBuzzService();
  // expected to pass
  assertEquals(1, fbs.buzzFizz("1", 1));
  assertEquals(101, fbs.buzzFizz("101", 1));
  assertEquals(3, fbs.buzzFizz("Fizz", 1));
  assertEquals(9, fbs.buzzFizz("Fizz", 3));
  assertEquals(5, fbs.buzzFizz("Buzz", 1));
  assertEquals(10, fbs.buzzFizz("Buzz", 2));
  assertEquals(15, fbs.buzzFizz("FizzBuzz", 1));
  assertEquals(30, fbs.buzzFizz("FizzBuzz", 2));
}
@Test
void testBuzzFizz_unclearRepetition() {
  FizzBuzzService fbs = new FizzBuzzService();
  // requirements unclear - does "FizzBuzz" count as a "Fizz" and a "Buzz" as well?
  // both these tests fail because they return '15', which is "FizzBuzz"
  assertEquals(18, fbs.buzzFizz("Fizz", 5));
  assertEquals(20, fbs.buzzFizz("Buzz", 3));
}
@Test
void testBuzzFizz_invalidStrings() {
  FizzBuzzService fbs = new FizzBuzzService();
  // should this be case insensitive?
  assertEquals(3, fbs.buzzFizz("fizz", 1)); //throws number format exception
  // what to do about nonsense input?
  assertThrows(IllegalArgumentException.class, () -> fbs.buzzFizz("tacocat", 1));
}
@Test
void testBuzzFizz_boundaryChecking() {
  FizzBuzzService fbs = new FizzBuzzService();
  // how should the program represent that no input produces the output. This example would
  // return the integer -1, which is incorrect. Should we throw an exception, return 0 or some other value?
  assertThrows(IllegalArgumentException.class, () -> fbs.buzzFizz("-1", 1));
  // what about integers recurrence? There should never be a second occurrence
  // of "1", so what do we expect the program to do?
  assertThrows(IllegalArgumentException.class, () -> fbs.buzzFizz("1", 2));
  // we can also enter invalid occurrence param for "Fizz" or "Buzz", getting back 0 or negative numbers
  assertThrows(IllegalArgumentException.class, () -> fbs.buzzFizz("Fizz", 0)); // returns 0
  assertThrows(IllegalArgumentException.class, () -> fbs.buzzFizz("Buzz", -1)); // returns -5
}

Selenium/WebDriver

Our goal in this section is to expand our testing acumen beyond simple unit tests into the realm of integration tests. Specifically, we want to be able to test our web application’s abilities from the high-level perspective of user actions. In order to do this, we need a way to programmatically simulate a user’s action in the browser. That’s where Selenium comes in.

Selenium is a cross-platform tool for browser automation and scripting, and we’re going to use it to write tests that simulate a user’s actions in a browser. In the next video, we’ll look at how Selenium’s API functions in detail.

The figure above shows the architecture of selenium. Test scripts written using Selenium’s Java API are translated by Selenium to work on different browsers using different drivers.

https://github.com/udacity/nd035-c1-spring-boot-basics-examples/tree/master/udacity-jwdnd-c1-l5-selenium-webdriver-master

In this video, we looked at the basic elements of a Selenium script. Here’s the full script we examined:

public static void main(String[] args) throws InterruptedException {
        WebDriverManager.chromedriver().setup();
        WebDriver driver = new ChromeDriver();
        driver.get("http://www.google.com");
        WebElement inputField = driver.findElement(By.name("q"));
        inputField.sendKeys("selenium");
        inputField.submit();
        List<WebElement> results = driver.findElements(By.cssSelector("div.g a"));
        for (WebElement element : results) {
            String link = element.getAttribute("href");
            System.out.println(link);
        }
        Thread.sleep(5000);
        driver.quit();
    }

Every Selenium script has to start by initializing a web driver. Since we’re using WebDriverManager (documentation links below), we can use it to automatically download the binary file for Selenium’s driver for Google Chrome, and then we can initialize the driver without any additional work.

Once we have a driver, we need to tell it which web page to visit. We do this with driver.get("http://www.google.com"); in the script, but if we were testing one of our own applications, like the message page from earlier this course, we would have to change the URL to something like http://localhost:8080/home.

In order to interact with or extract data from the web page, we first need to select the required HTML elements on the page. In this example, we use driver.findElement(By.name("q")); to select the google search input element. A detailed explanation of this process can be found below.

In order to interact with the elements we’ve selected, we can call various methods on them. In this case, we’re using inputField.sendKeys("selenium"); to simulate typing the word selenium into google, and we're using inputField.submit(); to simulate submitting the search form.

Once we’ve interacted with the web page, we want to read in the results and print them out. Again, we use the same process for finding an element, but this time, we use driver.findElements() to get a list of matching elements, instead of a single one.

The final part of every Selenium script is shutting down the driver. Since the driver is an external program, if we don’t call driver.quit(), the automated browser window will never close on its own.

Key Terms

  • Web Driver: In order for Selenium to assume control of a browser, it needs a program to interface with the specific browser’s API. This program is called a web driver, and there are different web drivers for each major browser.

Further Research

Solution: Selenium/WebDriver

Below is the sample solution. You can try playing around with the provided solution.

File: /l5e3/src/test/java/com/example/demo/SeleniumTest.java

package com.example.demo;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;
@SpringBootTest
public class SeleniumTest {
    @Test
    public static void main(String[] args) throws InterruptedException {
        //start the driver, open chrome to our target url
        WebDriverManager.chromedriver().setup();
        WebDriver driver = new ChromeDriver();
        driver.get("http://localhost:8080/animal");

        //find the fields we want by id and fill them in
        WebElement inputField = driver.findElement(By.id("animalText"));
        inputField.sendKeys("Manatee");
        inputField = driver.findElement(By.id("adjective"));
        inputField.sendKeys("Whirling");
        List<WebElement> trainingResults = driver.findElements(By.className("trainingMessage"));
        // The field-values don’t clear on submit for our simple app, so just submit it 5 times
        // However, the elements gets removed from the DOM structure after each submit.
        for(int i = 0; i < 5; i++) {
            // We are re-assigning the inputField because this element gets removed from the DOM structure after each iteration.
            // Otherwise, you'll get org.openqa.selenium.StaleElementReferenceException at runtime.
            inputField = driver.findElement(By.id("adjective"));
            inputField.submit();
            System.out.println("trainingResults.size() = " + trainingResults.size());
        }
        // then get the element by the class conclusionMessage and print it
        WebElement conclusionResult = driver.findElement(By.className("conclusionMessage"));
        System.out.println("conclusionResult.getText() = " + conclusionResult.getText());
        Thread.sleep(5000);
        driver.quit();
    }
}

You’ll notice that the web-browser closes automatically after all the iterations are completed.

Note — The code above is written for the Chrome browser. However, you can change the code for your respective browser. For other browsers, refer to the README of WebDriverManager for a corresponding method.

JUnit and Selenium

Selenium and JUnit are a natural fit for one another. Both are plain Java libraries, and don’t require any special syntax or approach to integrate with one another. We can use Selenium’s driver to navigate the web, interact with elements on the page, and extract data from those elements, and we can use JUnit’s assertions to check the data that is returned against expected values.

Selenium also requires some initialization logic, like setting up the web driver and navigating to the correct URL to perform further actions on. JUnit’s @BeforeAll annotation is perfect for writing a method to initialize the web driver, and we can use the @BeforeEach annotation to write a method that navigates to a common starting URL for all tests in the class. Finally, since we need to make sure we quit the web driver once our tests are finished, we can use JUnit's @AfterAll annotation to define a method that takes care of that.

Selenium provides another useful tool for JUnit test organization — the Page Object. A Page Object is a Java class that is meant to represent a specific web page under test. We can use Page Objects to reduce boilerplate when writing Selenium scripts, and, as we’ll see in the next video, we can even use them to make our test code resemble the user stories under test.

https://github.com/udacity/nd035-c1-spring-boot-basics-examples/tree/master/udacity-jwdnd-c1-l5-junit-and-selenium-master

In the previous video, we looked at a simple counter application, with some text to display the current count, an increment button, and a reset button. Our goal is to write some JUnit and Selenium code to test that all of the essential features of the app are functioning correctly. First, though, we want a Selenium Page Object to represent the page we’re testing. Here’s the full CounterPage class from the example:

public class CounterPage {

    @FindBy(id = "count-display")
    private WebElement countDisplay;

    @FindBy(id = "increment-button")
    private WebElement incrementButton;

    @FindBy(id = "reset-value-field")
    private WebElement resetValueField;

    @FindBy(id = "reset-button")
    private WebElement resetButton;

    public CounterPage(WebDriver driver) {
        PageFactory.initElements(driver, this);
    }

    public int getDisplayedCount() {
        return Integer.parseInt(countDisplay.getText());
    }

    public void incrementCount() {
        incrementButton.click();
    }

    public void resetCount(int value) {
        resetValueField.clear();
        resetValueField.sendKeys(String.valueOf(value));
        resetButton.click();
    }
}

There are three main sections to this, and any, Page Object:

Defining Element Selectors

@FindBy(id = "count-display")
    private WebElement countDisplay;

    @FindBy(id = "increment-button")
    private WebElement incrementButton;

    @FindBy(id = "reset-value-field")
    private WebElement resetValueField;

    @FindBy(id = "reset-button")
    private WebElement resetButton;

The goal of a Page Object is to simplify and abstract away common Selenium tasks, like finding elements on the page. Previously, we did this with driver.findElement and driver.findElements, but in a Page Object, we can take a much more Spring-like approach by declaring annotated fields representing the elements we want to capture on the page. These element selectors will be automatically processed by Selenium, but we have to kick that process off ourselves - which we do in the next section:

Initializing Elements in the Constructor

public CounterPage(WebDriver driver) {
        PageFactory.initElements(driver, this);
    }

In this example, we declare a WebDriver as the only constructor argument, and we call PageFactory.initElements() with the driver and the this keyword as arguments. This is shorthand to tell Selenium to use the given driver to initialize the @FindBy-annotated fields in the class. In principle, we could do this somewhere else, but as we'll see in the next video, initializing a Page Object in its constructor like this is pretty flexible and clean.

By adding this constructor, whenever we create a new CounterPage object, Selenium will automatically find and capture the elements we declared, reducing a bunch of similar calls to driver.findElement to a single new CounterPage() instantiation. Once we have those elements, we can move on to the next section:

Creating Helper Methods

public int getDisplayedCount() {
        return Integer.parseInt(countDisplay.getText());
    }

    public void incrementCount() {
        incrementButton.click();
    }

    public void resetCount(int value) {
        resetValueField.clear();
        resetValueField.sendKeys(String.valueOf(value));
        resetButton.click();
    }

Now that our Page Object has selected elements from the page it represents, we can define helper methods that encapsulate common tasks for the page. In this counter example, we need to be able to read the current count from the screen, we need to be able to increment the count, and we need to reset the count. Notice that I didn't mention any specific elements to describe the functionality of these actions - while we have to be specific in our implementation of these methods, as you can see in the code above, the goal of writing these helpers is to separate the action taken on the class from the specific element interactions required to fulfill that action. In some ways, this is another instance of separation of concerns - by hiding the implementation details in these methods, if the HTML of the page ever changes, we don't have to update anything except the code inside this class - the tests that will use this class can just continue to call the same methods they did before.

Speaking of tests - now that we've set up the CounterPage class, we can finally implement some tests for this app.

https://github.com/udacity/nd035-c1-spring-boot-basics-examples/tree/master/udacity-jwdnd-c1-l5-junit-and-selenium-master

Here’s the full JUnit test class from the previous video:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserTestingApplicationTests {
    @LocalServerPort
    private Integer port;
    private static WebDriver driver;
    private CounterPage counter;
    @BeforeAll
    public static void beforeAll() {
        WebDriverManager.chromedriver().setup();
        driver = new ChromeDriver();
    }
    @AfterAll
    public static void afterAll() {
        driver.quit();
    }
    @BeforeEach
    public void beforeEach() {
        driver.get("http://localhost:" + port + "/counter");
        counter = new CounterPage(driver);
    }
    @Test
    public void testIncrement() {
        int prevValue = counter.getDisplayedCount();
        counter.incrementCount();
        assertEquals(prevValue + 1, counter.getDisplayedCount());
    }
    @Test
    public void testIncrementTenTimes() {
        int prevValue = counter.getDisplayedCount();
        for (int i = 0; i < 10; i++) {
            assertEquals(prevValue + i, counter.getDisplayedCount());
            counter.incrementCount();
        }
    }
    @Test
    public void testReset() {
        counter.resetCount(10);
        assertEquals(10, counter.getDisplayedCount());
        counter.resetCount(0);
        assertEquals(0, counter.getDisplayedCount());
    }
}

There are a few things we have to do to set up a test file for a Spring Boot app. The main thing is that we have to make sure our server is running before the tests start — we do that here with @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT). This tells JUnit to run the application before any tests are executed, with a random port number instead of the default 8080. This is useful because it means we can have multiple copies of the app running at the same time, which is common in development and testing environments.

Of course, we need to know what the random port ends up being so that we can use Selenium’s driver.get() method to navigate the browser to our app. Spring makes this easy for us with the @LocalServerPort annotation. Spring will inject the current port into a field annotated with this like the example above.

As we mentioned in the video, we set up the Selenium driver in an @BeforeAll method, and we quit it in an @AfterAll method. However, the magic really starts with the @BeforeEach method - here, we navigate to the /countert URL and initialize a new CounterPage object. This means that every test will start from this URL and with a fresh CounterPage object - which makes test development extremely simple.

As you can see from the rest of the tests, we simply use the helper methods we defined on CounterPage to perform all actions in and retrieve all data from the browser. This makes our test code highly legible, and each test starts to look a lot like a user story - for example, for increment, we could read the test as

As a user, I can increment the count in order to see the displayed count increase by one

And the code doesn’t look far off from that statement! That’s a truly powerful abstraction.

Key Terms

  • Page Object: a special POJO variant that can be defined for use with Selenium. A Page Object should have @FindBy-annotated fields that represent the key HTML elements under test, and should have helper methods that define high-level utilities and user actions on the page under test.

Further Research

Page Load Times

In the real world, things get complicated, fast. Nowhere is this more apparent than when trying to account for page load times when automating user testing.

On the web, page load times can vary wildly according to different internet providers, the size of the resources a page has to load, the speed at which the server handles requests, and so on. It’s virtually impossible to predict exactly when a page will load, and this presents a problem for testing; if we ask Selenium to find an element on a page before the page finishes loading, it’s going to fail and we’re going to have something like a big, fat NullpointerException or a StaleElementReferenceException on our hands. The StaleElementReferenceException may occur in case of a delayed-reload.

So how do we make sure an element is on the page before we ask Selenium to look for it?

The answer is to use a WebDriverWait, which is a class Selenium provides just for this purpose. Let's look at the following code:

WebDriverWait wait = new WebDriverWait(driver, 10);
WebElement marker = wait.until(webDriver -> webDriver.findElement(By.id("page-load-marker")));

In this example, we create a new WebDriverWait instance using a driver and a timeout in seconds. WebDriverWait defines a method called until that we use in the next line to force Selenium to pause until the specified element is found, or the timeout is reached.

This is extremely handy, since we can now ensure that Selenium waits and continues in a structured way.

Further Research

Solution: Final Review

https://github.com/udacity/nd035-c1-spring-boot-basics-examples/tree/master/udacity-jwdnd-c1-l5-final-review-solution-master

For this solution explanation, we’re going to focus on the actual JUnit test that I asked you to implement. Since you had a lot of flexibility in how you could implement parts of this review, like the Page Objects, I won’t be talking about them here. If you want to see the Page Objects and other code I wrote for this solution, click the link above.

Here’s the final JUnit test method from my solution:

@Test
public void testUserSignupLoginAndSubmitMessage() {
    String username = "pzastoup";
    String password = "whatabadpassword";
    String messageText = "Hello!";
    driver.get(baseURL + "/signup");
    SignupPage signupPage = new SignupPage(driver);
    signupPage.signup("Peter", "Zastoupil", username, password);
    driver.get(baseURL + "/login");
    LoginPage loginPage = new LoginPage(driver);
    loginPage.login(username, password);
    ChatPage chatPage = new ChatPage(driver);
    chatPage.sendChatMessage(messageText);
    ChatMessage sentMessage = chatPage.getFirstMessage();
    assertEquals(username, sentMessage.getUsername());
    assertEquals(messageText, sentMessage.getMessageText());
}

As you can see, the abstraction of Page Objects makes this test very readable and simple to follow. First, we use the driver to navigate to the signup page. We initialize a SignupPage object and call the signup method with some user account details - in my Page Objects, every top-level user action is represented by a single, simple method like this.

For the sake of simplicity, we’re assuming that each form submission is a success, so we then navigate to the login page and create another Page Object for that screen. We log in with the same credentials we registered, and since our Spring Security configuration is set up to automatically forward us to the /chat URL on successful login, we don't have to navigate with driver.get again.

We then instantiate a ChatPage object, use its sendChatMessage method to send a new message to the chat, and the retrieve the sent message data from the browser. Notice that we're using the same ChatMessage class we're using throughout the application - don't be afraid to reuse POJOs like this when it fits the situation!

Finally we check that the username and message text in the ChatMessage matches what we submitted, and we're done!

Glossary

  • Test Driven Development: a software development methodology that emphasizes writing tests before the code to be tested. This gives developers a roadmap to success — once all the tests are passing, the feature is complete!
  • User Story: User stories are short sentences derived from feature requirements in the format of As a user, I can in order to . These are used to create tests to verify the components of a feature.
  • Unit Tests: A unit test only validates the smallest unit of a computational process. That might mean a test of a single method, or a single component in an application.
  • Invariants: An invariant is a law of computation, something that shouldn’t change despite changing circumstances. For example, adding 0 to a number should always result in the original number, and dividing by 0 should always result in an error.
  • Integration Tests: Integration tests are intended to validate the operation of multiple application components as they interact with each other — or integrate with one another.
  • Assertion: an assertion, in the context of JUnit, is a method we can call to check our assumptions about the behavior of the unit under test. If our assumptions are correct, the assertion silently returns and the test method continues. If they’re false, the assertion throws a special exception class that JUnit uses to build the final failure report, and the test method halts execution.
  • Interactive Reporting: When we run tests in an IDE, we can usually inspect the results of each test individually. If an assertion fails or an unexpected exception is triggered, the stack trace and circumstances will be shown in the details for each test, and clickable links in the results help you navigate to problem areas in your code.
  • Interactive Debugging: When a pernicious problem persists, it can often be helpful to step through the code’s execution line-by-line to inspect both the control flow and the values in memory used by the program. This is called debugging, and while it’s technically possible to do outside of an IDE, IDEs like IntelliJ provide many useful tools for making the process as painless as possible.
  • Code Coverage Reports: When we run code in an IDE like IntelliJ, we can choose to have the IDE track which lines of our code were visited, and how many times. This can be wildly useful when trying to track down why a branch of a condition isn’t being reached, as well as when determining how much the entire code base is covered by the currently-implemented tests.
  • Web Driver: In order for Selenium to assume control of a browser, it needs a program to interface with the specific browser’s API. This program is called a web driver, and there are different web drivers for each major browser.
  • Page Object: a special POJO variant that can be defined for use with Selenium. A Page Object should have @FindBy-annotated fields that represent the key HTML elements under test, and should have helper methods that define high-level utilities and user actions on the page under test.

 

반응형

'Tech > Java Web Development' 카테고리의 다른 글

Rest APIs — DogRestAPI  (0) 2025.05.13
Data Persistence & Security  (1) 2025.05.11
Spring MVC and Thymeleaf  (1) 2025.05.10
Spring boot for web development  (0) 2025.05.09
Web Development in Java  (2) 2025.05.08