1 Commits

Author SHA1 Message Date
b414beb6fb ДЗ #2 2026-02-14 21:56:07 +03:00
23 changed files with 707 additions and 266 deletions

View File

@@ -1,45 +1,51 @@
# OTUS Selenium Homework 1
# OTUS Selenium Homework 2 (BDD + Cucumber)
## Цель проекта
Автоматизировать 3 UI-сценария на `https://otus.ru` с использованием Selenium WebDriver 4+, JUnit 6, Guice DI, listeners, Stream API, Jsoup и обязательных проверок качества (Checkstyle + SpotBugs).
Реализовать BDD-подход на `https://otus.ru` с использованием Selenium WebDriver 4+, Cucumber (русские feature), JUnit 5, Guice DI, listeners, Stream API, Jsoup и проверок качества (Checkstyle + SpotBugs).
## Стек технологий
- Java 21
- Maven
- Selenium `4.40.0`
- WebDriverManager `6.3.3`
- JUnit `6.0.2`
- Guice `7.0.0`
- Jsoup `1.22.1`
- Guava `33.5.0-jre`
- SLF4J `2.0.17`
- Logback `1.5.31`
- Selenium `4.38.0`
- JUnit 5
- Cucumber (`cucumber-java`, `cucumber-junit-platform-engine`)
- Guice
- Jsoup
- Checkstyle
- SpotBugs
## Реализованные сценарии
1. Поиск курса по имени.
- Открытие каталога `https://otus.ru/catalog/courses`
- Поиск курса по имени через Stream API
- Клик по плитке курса
- Проверка заголовка открытого курса
## Реализованные BDD-сценарии
1. Выбор браузера через feature.
- Шаг: `Дано Открыт браузер Chrome`
- Фабрика драйвера выбирается по `browser` property (`chrome`)
2. Самые ранние/поздние курсы по дате старта.
- Открытие каталога `https://otus.ru/catalog/courses`
- Поиск ранних и поздних курсов через Stream API + `reduce`
- При совпадении дат проверяются все курсы с этой датой
- Проверка названия и даты старта на странице курса через Jsoup
2. Поиск курса по имени и случайный выбор при множественных результатах.
- Название курса задается в feature
- Выбирается случайный курс из найденных
- Проверяется заголовок открытой страницы курса
3. Случайная категория с главной страницы.
- Открытие `https://otus.ru`
- Открытие меню «Обучение»
- Выбор случайной категории
- Проверка, что открыта корректная категория
3. Поиск курсов, стартующих в указанную дату или позже.
- Дата задается в feature (`dd.MM.yyyy`)
- Выполняется фильтрация `startDate >= dateFrom`
- В консоль выводится: название + дата старта
4. Раздел «Обучение» -> «Подготовительные курсы».
- Открывается пункт `Подготовительные курсы` из меню `Обучение`
- При необходимости нажимается `Показать еще ...`, чтобы загрузить весь список
- Из списка выбираются самый дорогой и самый дешевый
- Выбор реализован через Stream API + `filter`
- Информация о курсах выводится в консоль
5. Общий каталог курсов: самый дорогой и самый дешевый по полной стоимости со скидкой.
- Открывается `https://otus.ru/catalog/courses`
- Для каждого курса берется цена сравнения:
- В приоритете `Полная` -> `Полная стоимость со скидкой`
- Для упрощенных online-страниц используется fallback по видимой цене
- Через `filter` выбираются max/min, результат выводится в консоль
## Архитектура
- 2-уровневый тест-дизайн: `tests` + `page objects`
- DI через Guice для тестов и страниц
- JUnit 6 Extension (`GuiceExtension`), без базового класса-теста
- 2-уровневый тест-дизайн: `BDD steps` + `page objects`
- DI через Guice для step definitions и страниц
- Фабрика драйвера:
- `DriverFactory` (интерфейс)
- `ChromeDriverFactory` (реализация)
@@ -55,8 +61,10 @@
- `src/main/java/ru/kovbasa/listeners` — listener подсветки
- `src/main/java/ru/kovbasa/pages` — Page Object классы
- `src/main/java/ru/kovbasa/elements` — типизированные UI-элементы
- `src/test/java/ru/kovbasa/config` — JUnit extension для DI
- `src/test/java/ru/kovbasa/tests` — автотесты
- `src/test/resources/features``.feature` файлы (русский язык)
- `src/test/java/ru/kovbasa/bdd` — Cucumber runner
- `src/test/java/ru/kovbasa/bdd/steps` — step definitions
- `src/test/java/ru/kovbasa/bdd/hooks` — lifecycle hooks
## Требования к окружению
1. Установлен JDK 21 (доступен в `PATH`)
@@ -65,33 +73,36 @@
4. Есть доступ в интернет и к `otus.ru` (тесты запускаются на живом сайте)
## Запуск
### 1. Запуск только тестов
### 1. Запуск всех тестов
```bash
mvn test
```
### 2. Полная проверка (тесты + Checkstyle + SpotBugs)
### 2. Запуск конкретного Cucumber runner
```bash
mvn verify
mvn "-Dtest=ru.kovbasa.bdd.CucumberTest" test
```
### 3. Запуск отдельного тестового класса
### 3. Локальная полная проверка (необязательно для сдачи ДЗ)
```bash
mvn "-Dtest=ru.kovbasa.tests.CourseSearchTest" test
mvn verify
```
## Параметры запуска
Пробрасываются через Maven Surefire:
- `base.url` (по умолчанию `https://otus.ru`)
- `course.name` (по умолчанию `Python Developer`)
- `browser` (по умолчанию `chrome`)
Пример переопределения:
```bash
mvn "-Dcourse.name=Python Developer" test
mvn "-Dbrowser=chrome" "-Dcourse.name=Python" test
```
## Quality Gates
- Checkstyle и SpotBugs выполняется в фазе `verify`
- Checkstyle и SpotBugs выполняются в фазе `verify` (опционально)
## Примечания
- Тесты зависят от текущей верстки/контента `otus.ru`.
- По умолчанию в `mvn test` запускается только `CucumberTest`.
- Для сдачи ДЗ достаточно успешного запуска `mvn test`.

64
pom.xml
View File

@@ -16,22 +16,23 @@
<!-- Конфиг тестов -->
<base.url>https://otus.ru</base.url>
<course.name>Python Developer</course.name>
<browser>chrome</browser>
<!-- Dependencies -->
<selenium.version>4.40.0</selenium.version>
<junit.version>6.0.2</junit.version>
<webdrivermanager.version>6.3.3</webdrivermanager.version>
<guice.version>7.0.0</guice.version>
<jsoup.version>1.22.1</jsoup.version>
<slf4j.version>2.0.17</slf4j.version>
<logback.version>1.5.31</logback.version>
<guava.version>33.5.0-jre</guava.version>
<selenium.version>4.38.0</selenium.version>
<junit.version>5.10.0</junit.version>
<junit.platform.suite.version>1.10.0</junit.platform.suite.version>
<cucumber.version>7.20.1</cucumber.version>
<guice.version>5.1.0</guice.version>
<jsoup.version>1.21.2</jsoup.version>
<slf4j.version>2.0.11</slf4j.version>
<logback.version>1.4.14</logback.version>
<!-- Plugins -->
<maven.compiler.version>3.15.0</maven.compiler.version>
<surefire.version>3.5.4</surefire.version>
<maven.compiler.version>3.11.0</maven.compiler.version>
<surefire.version>3.1.2</surefire.version>
<checkstyle.plugin.version>3.6.0</checkstyle.plugin.version>
<spotbugs.plugin.version>4.9.8.2</spotbugs.plugin.version>
<spotbugs.plugin.version>4.9.8.0</spotbugs.plugin.version>
<spotbugs.version>4.9.8</spotbugs.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -45,13 +46,6 @@
<version>${selenium.version}</version>
</dependency>
<!-- WebDriverManager -->
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>${webdrivermanager.version}</version>
</dependency>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
@@ -60,6 +54,27 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<version>${junit.platform.suite.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<!-- Guice -->
<dependency>
<groupId>com.google.inject</groupId>
@@ -87,12 +102,6 @@
<version>${logback.version}</version>
</dependency>
<!-- Guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
</dependencies>
<build>
@@ -115,9 +124,16 @@
<version>${surefire.version}</version>
<configuration>
<useModulePath>false</useModulePath>
<argLine>
-Djava.util.logging.config.file=${project.basedir}/src/test/resources/logging.properties
</argLine>
<includes>
<include>**/CucumberTest.java</include>
</includes>
<systemPropertyVariables>
<base.url>${base.url}</base.url>
<course.name>${course.name}</course.name>
<browser>${browser}</browser>
</systemPropertyVariables>
</configuration>
</plugin>

View File

@@ -2,15 +2,15 @@ package ru.kovbasa.config;
import com.google.inject.AbstractModule;
import com.google.inject.Singleton;
import ru.kovbasa.driver.ChromeDriverFactory;
import ru.kovbasa.driver.DriverFactory;
import ru.kovbasa.driver.SelectableDriverFactory;
import ru.kovbasa.driver.WebDriverProvider;
public class DriverModule extends AbstractModule {
@Override
protected void configure() {
bind(DriverFactory.class).to(ChromeDriverFactory.class).in(Singleton.class);
bind(DriverFactory.class).to(SelectableDriverFactory.class).in(Singleton.class);
bind(WebDriverProvider.class).in(Singleton.class);
}
}

View File

@@ -2,20 +2,18 @@ package ru.kovbasa.config;
public final class TestConfig {
private static final String BASE_URL =
System.getProperty("base.url", "https://otus.ru");
private static final String COURSE_NAME =
System.getProperty("course.name", "Python Developer");
private TestConfig() {
}
public static String getBaseUrl() {
return BASE_URL;
return System.getProperty("base.url", "https://otus.ru");
}
public static String getCourseName() {
return COURSE_NAME;
return System.getProperty("course.name", "Python Developer");
}
public static String getBrowser() {
return System.getProperty("browser", "chrome");
}
}

View File

@@ -0,0 +1,19 @@
package ru.kovbasa.driver;
import org.openqa.selenium.WebDriver;
import ru.kovbasa.config.TestConfig;
public class SelectableDriverFactory implements DriverFactory {
private final DriverFactory chromeFactory = new ChromeDriverFactory();
@Override
public WebDriver createDriver() {
final String browser = TestConfig.getBrowser().toLowerCase();
if (!"chrome".equals(browser)) {
throw new IllegalArgumentException(
"Unsupported browser: " + browser + ". Supported browser: chrome");
}
return chromeFactory.createDriver();
}
}

View File

@@ -3,8 +3,6 @@ package ru.kovbasa.driver;
import com.google.inject.Inject;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.events.EventFiringDecorator;
import io.github.bonigarcia.wdm.WebDriverManager;
import ru.kovbasa.listeners.HighlightElementListener;
public final class WebDriverProvider {
@@ -12,10 +10,6 @@ public final class WebDriverProvider {
private WebDriver driver;
private final DriverFactory driverFactory;
static {
WebDriverManager.chromedriver().setup();
}
@Inject
public WebDriverProvider(DriverFactory driverFactory) {
this.driverFactory = driverFactory;

View File

@@ -7,11 +7,14 @@ import java.time.LocalDate;
import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class CourseCard extends BaseElement {
private final By titleLocator = By.cssSelector("h6 .sc-hrqzy3-1");
private final By dateLocator = By.cssSelector(".sc-157icee-1 .sc-hrqzy3-1");
private static final Pattern PRICE_PATTERN = Pattern.compile("(\\d[\\d\\s]*)\\s*[₽р]");
public CourseCard(WebElement element) {
super(element);
@@ -51,4 +54,24 @@ public class CourseCard extends BaseElement {
return LocalDate.parse(normalized, formatter);
}
}
public int price() {
final String text = element.getText();
final Matcher matcher = PRICE_PATTERN.matcher(text);
int maxPrice = -1;
while (matcher.find()) {
final String raw = matcher.group(1).replace(" ", "");
final int parsed = Integer.parseInt(raw);
if (parsed > maxPrice) {
maxPrice = parsed;
}
}
if (maxPrice < 0) {
throw new RuntimeException("Цена курса не найдена в карточке: " + title());
}
return maxPrice;
}
}

View File

@@ -1,5 +1,7 @@
package ru.kovbasa.listeners;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
@@ -8,6 +10,7 @@ import org.openqa.selenium.WrapsDriver;
import org.openqa.selenium.support.events.WebDriverListener;
public class HighlightElementListener implements WebDriverListener {
private static final Logger LOG = LoggerFactory.getLogger(HighlightElementListener.class);
private static final String HIGHLIGHT_STYLE =
"outline: 4px solid red !important; " +
@@ -20,16 +23,11 @@ public class HighlightElementListener implements WebDriverListener {
public void beforeClick(WebElement element) {
try {
clearHighlight();
final String tag = safeGetTag(element);
final String href = safeGetAttr(element, "href");
final String cls = safeGetAttr(element, "class");
System.out.println("beforeClick: tag=" + tag + " href=" + href + " class=" + cls);
final WebElement tile = findTileContainer(element);
applyHighlight(tile);
} catch (Exception e) {
System.out.println("Highlight listener error: " + e.getMessage());
pauseForVisibility();
} catch (RuntimeException e) {
LOG.debug("beforeClick highlight skipped", e);
}
}
@@ -62,8 +60,8 @@ public class HighlightElementListener implements WebDriverListener {
js.executeScript("arguments[0].setAttribute('style', arguments[1] + '; ' + arguments[2]);",
element, original, HIGHLIGHT_STYLE);
} catch (StaleElementReferenceException ignored) {
} catch (Exception e) {
System.out.println("applyHighlight error: " + e.getMessage());
} catch (RuntimeException e) {
LOG.debug("applyHighlight skipped", e);
}
}
@@ -84,8 +82,8 @@ public class HighlightElementListener implements WebDriverListener {
final String original = highlightedOriginalStyle == null ? "" : highlightedOriginalStyle;
js.executeScript("arguments[0].setAttribute('style', arguments[1]);", highlightedElement, original);
} catch (StaleElementReferenceException ignored) {
} catch (Exception e) {
System.out.println("clearHighlight error: " + e.getMessage());
} catch (RuntimeException e) {
LOG.debug("clearHighlight skipped", e);
} finally {
highlightedElement = null;
highlightedOriginalStyle = null;
@@ -134,7 +132,8 @@ public class HighlightElementListener implements WebDriverListener {
return current;
}
}
} catch (Exception ignored) {
} catch (RuntimeException e) {
LOG.debug("findTileContainer fallback to original element", e);
}
return element;
}
@@ -148,4 +147,12 @@ public class HighlightElementListener implements WebDriverListener {
try { return el.getTagName(); }
catch (Exception e) { return null; }
}
private void pauseForVisibility() {
try {
Thread.sleep(120);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

View File

@@ -3,9 +3,13 @@ package ru.kovbasa.pages;
import com.google.inject.Inject;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
@@ -26,7 +30,11 @@ public class CatalogPage {
private final WebDriver driver;
private final By courseCards = By.cssSelector("a.sc-zzdkm7-0");
private final By courseLinks = By.cssSelector("a[href*='/lessons/']");
private final By courseLinks = By.cssSelector("a[href*='/lessons/'], a[href*='/online/']");
private final By learningMenu = By.cssSelector("span[title='Обучение']");
private final By prepCoursesLink = By.xpath(
"//a[contains(normalize-space(),'Подготовительные курсы')]");
private final By showMoreButton = By.xpath("//button[contains(normalize-space(),'Показать еще')]");
@Inject
public CatalogPage(WebDriverProvider provider) {
@@ -50,6 +58,15 @@ public class CatalogPage {
"Course not found in catalog by name: " + name));
}
public List<WebElement> findCoursesByName(String name) {
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseLinks).isEmpty());
return driver.findElements(courseLinks).stream()
.filter(e -> e.getText().toLowerCase().contains(name.toLowerCase()))
.toList();
}
public CoursePage clickCourseByName(String name) {
PageUtils.removeBottomBanner(driver);
@@ -66,6 +83,27 @@ public class CatalogPage {
return new CoursePage(driver);
}
public CoursePage clickRandomCourseByName(String name) {
PageUtils.removeBottomBanner(driver);
final List<WebElement> courses = findCoursesByName(name);
if (courses.isEmpty()) {
throw new NoSuchElementException("Course not found in catalog by name: " + name);
}
final WebElement chosen = courses.get(ThreadLocalRandom.current().nextInt(courses.size()));
((JavascriptExecutor) driver)
.executeScript("arguments[0].scrollIntoView({block:'center'});", chosen);
new Actions(driver)
.moveToElement(chosen)
.perform();
chosen.click();
return new CoursePage(driver);
}
public List<CourseCard> getAllCourseCards() {
return driver.findElements(courseCards).stream()
.map(CourseCard::new)
@@ -113,6 +151,185 @@ public class CatalogPage {
.toList();
}
public List<CourseItem> findCoursesStartingFrom(LocalDate dateFrom) {
return getAllCourses().stream()
.filter(course -> !course.startDate().isBefore(dateFrom))
.sorted(Comparator.comparing(CourseItem::startDate).thenComparing(CourseItem::title))
.toList();
}
public CatalogPage openPreparatoryCourses() {
driver.get(TestConfig.getBaseUrl());
PageUtils.removeBottomBanner(driver);
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
final WebElement menu = wait.until(drv -> drv.findElement(learningMenu));
menu.click();
final WebElement prep = wait.until(drv -> drv.findElements(prepCoursesLink).stream()
.filter(WebElement::isDisplayed)
.findFirst()
.orElse(null));
((JavascriptExecutor) driver)
.executeScript("arguments[0].scrollIntoView({block:'center'});", prep);
prep.click();
wait.until(drv -> drv.findElements(
By.xpath("//*[contains(normalize-space(),'Подготовительные курсы')]")
).stream().anyMatch(WebElement::isDisplayed));
expandAllCourses();
return this;
}
public List<PricedCourseItem> getAllCoursesWithPrice() {
return getAllCourseCards().stream()
.map(card -> {
try {
return new PricedCourseItem(card.title(), card.price());
} catch (RuntimeException e) {
return null;
}
})
.filter(Objects::nonNull)
.toList();
}
public List<CourseLinkItem> getPreparatoryCourseLinks() {
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseLinks).isEmpty());
final Map<String, String> titleByUrl = new LinkedHashMap<>();
for (WebElement link : driver.findElements(courseLinks)) {
if (!link.isDisplayed()) {
continue;
}
final String title = link.getText().trim();
final String url = link.getAttribute("href");
if (title.isEmpty() || url == null || !isCourseUrl(url)) {
continue;
}
titleByUrl.putIfAbsent(url, title);
}
final List<Map.Entry<String, String>> onlineEntries = titleByUrl.entrySet().stream()
.filter(entry -> entry.getKey().contains("/online/"))
.toList();
final List<CourseLinkItem> items = new ArrayList<>();
if (!onlineEntries.isEmpty()) {
onlineEntries.forEach(entry -> items.add(new CourseLinkItem(cleanTitle(entry.getValue()), entry.getKey())));
return items;
}
titleByUrl.forEach((url, title) -> items.add(new CourseLinkItem(cleanTitle(title), url)));
return items;
}
public List<PricedCourseItem> getPreparatoryCoursesWithDiscountedFullPrice() {
final List<CourseLinkItem> links = getPreparatoryCourseLinks();
return links.stream()
.map(link -> {
driver.get(link.url());
final CoursePage coursePage = new CoursePage(driver);
final int price = coursePage.getPriceForComparison();
String title = link.title();
try {
title = coursePage.getCourseTitle();
} catch (RuntimeException ignored) { }
return new PricedCourseItem(title, price);
})
.toList();
}
public List<CourseLinkItem> getCatalogLessonCourseLinks() {
open();
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
wait.until((ExpectedCondition<Boolean>) drv -> !drv.findElements(courseLinks).isEmpty());
final Map<String, String> titleByUrl = new LinkedHashMap<>();
for (WebElement link : driver.findElements(courseLinks)) {
if (!link.isDisplayed()) {
continue;
}
final String title = link.getText().trim();
final String url = link.getAttribute("href");
if (title.isEmpty() || url == null || !url.contains("/lessons/")) {
continue;
}
titleByUrl.putIfAbsent(url, cleanTitle(title));
}
final List<CourseLinkItem> items = new ArrayList<>();
titleByUrl.forEach((url, title) -> items.add(new CourseLinkItem(title, url)));
return items;
}
public List<PricedCourseItem> getCatalogCoursesWithDiscountedFullPrice() {
final List<CourseLinkItem> links = getCatalogLessonCourseLinks();
return links.stream()
.map(link -> {
driver.get(link.url());
final CoursePage coursePage = new CoursePage(driver);
final int price = coursePage.getPriceForComparison();
String title = link.title();
try {
title = coursePage.getCourseTitle();
} catch (RuntimeException ignored) { }
return new PricedCourseItem(title, price);
})
.toList();
}
private void expandAllCourses() {
final WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
while (true) {
try {
final WebElement button = wait.until(drv -> drv.findElements(showMoreButton).stream()
.filter(WebElement::isDisplayed)
.findFirst()
.orElse(null));
((JavascriptExecutor) driver)
.executeScript("arguments[0].scrollIntoView({block:'center'});", button);
button.click();
wait.until(drv -> !drv.findElements(courseLinks).isEmpty());
} catch (RuntimeException ignored) {
break;
}
}
}
private boolean isCourseUrl(String url) {
return url.contains("/lessons/") || url.contains("/online/");
}
private String cleanTitle(String rawTitle) {
final String[] lines = rawTitle.split("\\R");
for (int i = lines.length - 1; i >= 0; i--) {
final String line = lines[i].trim();
if (!line.isEmpty()) {
return line;
}
}
return rawTitle.trim();
}
public PricedCourseItem findMostExpensiveCourse() {
return getAllCoursesWithPrice().stream()
.reduce((left, right) -> left.price() >= right.price() ? left : right)
.orElseThrow(() -> new NoSuchElementException("Priced course cards are not found"));
}
public PricedCourseItem findCheapestCourse() {
return getAllCoursesWithPrice().stream()
.reduce((left, right) -> left.price() <= right.price() ? left : right)
.orElseThrow(() -> new NoSuchElementException("Priced course cards are not found"));
}
public CoursePage openCourse(CourseItem course) {
PageUtils.removeBottomBanner(driver);

View File

@@ -0,0 +1,4 @@
package ru.kovbasa.pages;
public record CourseLinkItem(String title, String url) {
}

View File

@@ -4,18 +4,31 @@ import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.Actions;
import org.openqa.selenium.support.ui.WebDriverWait;
import ru.kovbasa.elements.Button;
import ru.kovbasa.utils.PageUtils;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class CoursePage {
private static final Pattern PRICE_PATTERN = Pattern.compile("(\\d[\\d\\s\\u00A0]*)\\s*₽");
private final WebDriver driver;
private final By enrollButton = By.cssSelector("button[data-testid='enroll-button']");
private final By fullPriceTab = By.xpath("//div[normalize-space()='Полная']/parent::div");
private final By fullDiscountLabel = By.xpath("//p[contains(normalize-space(),'Полная стоимость со скидкой')]");
private final By anyPriceText = By.xpath("//*[contains(normalize-space(),'₽')]");
public CoursePage(WebDriver driver) {
this.driver = driver;
@@ -59,4 +72,68 @@ public class CoursePage {
public void clickEnroll() {
getEnrollButton().click();
}
}
public int getDiscountedFullPrice() {
PageUtils.removeBottomBanner(driver);
final WebDriverWait wait = new WebDriverWait(driver, java.time.Duration.ofSeconds(10));
final WebElement tab = wait.until(drv -> drv.findElements(fullPriceTab).stream()
.filter(WebElement::isDisplayed)
.findFirst()
.orElse(null));
((JavascriptExecutor) driver).executeScript("arguments[0].scrollIntoView({block:'center'});", tab);
try {
tab.click();
} catch (RuntimeException ignored) {
new Actions(driver).moveToElement(tab).click().perform();
}
final WebElement label = wait.until(drv -> drv.findElements(fullDiscountLabel).stream()
.filter(WebElement::isDisplayed)
.findFirst()
.orElse(null));
final WebElement priceElement = findPriceElementNearLabel(label);
final String rawPrice = priceElement.getText();
return parsePrice(rawPrice);
}
public int getPriceForComparison() {
if (hasDisplayed(fullPriceTab)) {
try {
return getDiscountedFullPrice();
} catch (RuntimeException ignored) { }
}
final WebDriverWait wait = new WebDriverWait(driver, java.time.Duration.ofSeconds(10));
final WebElement priceElement = wait.until(drv -> drv.findElements(anyPriceText).stream()
.filter(WebElement::isDisplayed)
.filter(el -> PRICE_PATTERN.matcher(el.getText()).find())
.findFirst()
.orElse(null));
return parsePrice(priceElement.getText());
}
private WebElement findPriceElementNearLabel(WebElement label) {
try {
return label.findElement(By.xpath("following-sibling::div[1]"));
} catch (NoSuchElementException ignored) {
return label.findElement(By.xpath("following::div[contains(normalize-space(),'₽')][1]"));
}
}
private int parsePrice(String rawPrice) {
final Matcher matcher = PRICE_PATTERN.matcher(rawPrice);
if (!matcher.find()) {
throw new IllegalArgumentException("Не удалось распарсить цену из строки: " + rawPrice);
}
final String normalized = matcher.group(1).replace('\u00A0', ' ').replace(" ", "");
return Integer.parseInt(normalized);
}
private boolean hasDisplayed(By by) {
return driver.findElements(by).stream().anyMatch(WebElement::isDisplayed);
}
}

View File

@@ -50,7 +50,6 @@ public class MainPage {
final WebElement chosen = els.get(ThreadLocalRandom.current().nextInt(els.size()));
final String href = chosen.getAttribute("href");
System.out.println("Selected category href = " + href);
((JavascriptExecutor) driver)
.executeScript("arguments[0].scrollIntoView({block:'center'});", chosen);

View File

@@ -0,0 +1,4 @@
package ru.kovbasa.pages;
public record PricedCourseItem(String title, int price) {
}

View File

@@ -0,0 +1,17 @@
package ru.kovbasa.bdd;
import org.junit.platform.suite.api.ConfigurationParameter;
import org.junit.platform.suite.api.IncludeEngines;
import org.junit.platform.suite.api.SelectClasspathResource;
import org.junit.platform.suite.api.Suite;
import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME;
import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME;
@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "ru.kovbasa.bdd")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty")
public class CucumberTest {
}

View File

@@ -0,0 +1,15 @@
package ru.kovbasa.bdd.hooks;
import com.google.inject.Injector;
import io.cucumber.java.After;
import ru.kovbasa.config.InjectorProvider;
import ru.kovbasa.driver.WebDriverProvider;
public class Hooks {
@After
public void afterScenario() {
final Injector injector = InjectorProvider.getInjector();
injector.getInstance(WebDriverProvider.class).quit();
}
}

View File

@@ -0,0 +1,159 @@
package ru.kovbasa.bdd.steps;
import com.google.inject.Injector;
import io.cucumber.java.ru.Дано;
import io.cucumber.java.ru.И;
import io.cucumber.java.ru.Когда;
import io.cucumber.java.ru.Тогда;
import org.junit.jupiter.api.Assertions;
import ru.kovbasa.config.InjectorProvider;
import ru.kovbasa.pages.CatalogPage;
import ru.kovbasa.pages.CourseItem;
import ru.kovbasa.pages.CoursePage;
import ru.kovbasa.pages.PricedCourseItem;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
public class CatalogSteps {
private static final DateTimeFormatter DATE_FORMATTER =
DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.forLanguageTag("ru"));
private CoursePage openedCoursePage;
private List<CourseItem> coursesStartingFromDate;
private LocalDate searchDate;
private PricedCourseItem mostExpensiveCourse;
private PricedCourseItem cheapestCourse;
private PricedCourseItem mostExpensiveCatalogCourse;
private PricedCourseItem cheapestCatalogCourse;
@Дано("Открыт браузер {word}")
public void openBrowser(String browser) {
System.setProperty("browser", browser.toLowerCase(Locale.ROOT));
}
@Когда("Открыт каталог курсов")
public void openCatalog() {
getCatalogPage().open();
}
@Когда("Найден курс {string} и выбран случайный из найденных")
public void findCourseAndOpenRandom(String courseName) {
openedCoursePage = getCatalogPage().clickRandomCourseByName(courseName);
}
@Тогда("Заголовок страницы курса содержит {string}")
public void coursePageTitleContains(String expectedCourseName) {
final String title = openedCoursePage.getCourseTitle();
Assertions.assertTrue(
title.toLowerCase(Locale.ROOT).contains(expectedCourseName.toLowerCase(Locale.ROOT)),
"Заголовок страницы курса должен содержать: " + expectedCourseName + ", фактически: " + title
);
}
@Когда("Найдены курсы со стартом {string} или позже")
public void findCoursesFromDate(String date) {
searchDate = LocalDate.parse(date, DATE_FORMATTER);
coursesStartingFromDate = getCatalogPage().findCoursesStartingFrom(searchDate);
}
@Тогда("В консоль выведены найденные курсы и даты старта")
public void printCoursesAndStartDates() {
Assertions.assertFalse(coursesStartingFromDate.isEmpty(), "Не найдено курсов по заданной дате");
coursesStartingFromDate.forEach(course -> System.out.println(
"Курс: " + course.title() + ", дата старта: " + course.startDate().format(DATE_FORMATTER)
));
final boolean allAfterOrEqual = coursesStartingFromDate.stream()
.allMatch(course -> !course.startDate().isBefore(searchDate));
Assertions.assertTrue(
allAfterOrEqual,
"Среди найденных есть курсы с датой старта раньше " + searchDate.format(DATE_FORMATTER)
);
}
@Когда("Открыт раздел Обучение и Подготовительные курсы")
public void openPreparatoryCourses() {
getCatalogPage().openPreparatoryCourses();
}
("Выбраны самый дорогой и самый дешевый курсы с помощью filter")
public void findMostExpensiveAndCheapestByFilter() {
final List<PricedCourseItem> pricedCourses = getCatalogPage().getPreparatoryCoursesWithDiscountedFullPrice();
Assertions.assertFalse(pricedCourses.isEmpty(), "Не найдено курсов с ценой");
final int maxPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).max().orElseThrow();
final int minPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).min().orElseThrow();
mostExpensiveCourse = pricedCourses.stream()
.filter(course -> course.price() == maxPrice)
.findFirst()
.orElseThrow();
cheapestCourse = pricedCourses.stream()
.filter(course -> course.price() == minPrice)
.findFirst()
.orElseThrow();
}
@Тогда("В консоль выведена информация о самом дорогом и самом дешевом курсе")
public void printMostExpensiveAndCheapestCourse() {
System.out.println(
"Самый дорогой курс: " + mostExpensiveCourse.title() + ", цена: " + mostExpensiveCourse.price()
);
System.out.println(
"Самый дешевый курс: " + cheapestCourse.title() + ", цена: " + cheapestCourse.price()
);
Assertions.assertTrue(
mostExpensiveCourse.price() >= cheapestCourse.price(),
"Цена самого дорогого курса должна быть не меньше цены самого дешевого"
);
}
@Когда("В каталоге курсов выбраны самый дорогой и самый дешевый курсы по полной стоимости со скидкой с помощью filter")
public void findMostExpensiveAndCheapestInCatalogByFullDiscountedPrice() {
final List<PricedCourseItem> pricedCourses = getCatalogPage().getCatalogCoursesWithDiscountedFullPrice();
Assertions.assertFalse(pricedCourses.isEmpty(), "Не найдено курсов с ценой в общем каталоге");
final int maxPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).max().orElseThrow();
final int minPrice = pricedCourses.stream().mapToInt(PricedCourseItem::price).min().orElseThrow();
mostExpensiveCatalogCourse = pricedCourses.stream()
.filter(course -> course.price() == maxPrice)
.findFirst()
.orElseThrow();
cheapestCatalogCourse = pricedCourses.stream()
.filter(course -> course.price() == minPrice)
.findFirst()
.orElseThrow();
}
@Тогда("В консоль выведена информация о самом дорогом и самом дешевом курсе в каталоге")
public void printMostExpensiveAndCheapestCourseInCatalog() {
System.out.println(
"Самый дорогой курс в каталоге: "
+ mostExpensiveCatalogCourse.title() + ", цена: " + mostExpensiveCatalogCourse.price()
);
System.out.println(
"Самый дешевый курс в каталоге: "
+ cheapestCatalogCourse.title() + ", цена: " + cheapestCatalogCourse.price()
);
Assertions.assertTrue(
mostExpensiveCatalogCourse.price() >= cheapestCatalogCourse.price(),
"Цена самого дорогого курса в каталоге должна быть не меньше цены самого дешевого"
);
}
private CatalogPage getCatalogPage() {
final Injector injector = InjectorProvider.getInjector();
return injector.getInstance(CatalogPage.class);
}
}

View File

@@ -1,29 +0,0 @@
package ru.kovbasa.config;
import com.google.inject.Injector;
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestInstancePostProcessor;
import ru.kovbasa.driver.WebDriverProvider;
public class GuiceExtension implements TestInstancePostProcessor, BeforeEachCallback, AfterEachCallback {
@Override
public void postProcessTestInstance(Object testInstance, ExtensionContext context) {
final Injector injector = InjectorProvider.getInjector();
injector.injectMembers(testInstance);
}
@Override
public void beforeEach(ExtensionContext context) {
final Injector injector = InjectorProvider.getInjector();
injector.getInstance(WebDriverProvider.class).getDriver();
}
@Override
public void afterEach(ExtensionContext context) {
final Injector injector = InjectorProvider.getInjector();
injector.getInstance(WebDriverProvider.class).quit();
}
}

View File

@@ -1,35 +0,0 @@
package ru.kovbasa.tests;
import com.google.inject.Inject;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.Test;
import ru.kovbasa.config.GuiceExtension;
import ru.kovbasa.driver.WebDriverProvider;
import ru.kovbasa.pages.MainPage;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ExtendWith(GuiceExtension.class)
public class CategoryRandomTest {
@Inject
private MainPage main;
@Inject
private WebDriverProvider webDriverProvider;
@Test
void randomCategoryOpensCorrectCatalog() {
main.open();
final String selectedHref = main.clickRandomCategory();
final String category = selectedHref.substring(selectedHref.lastIndexOf("/") + 1);
final String currentUrl = webDriverProvider.getDriver().getCurrentUrl();
assertTrue(
currentUrl.contains(category),
"Catalog URL should contain selected category. Selected: "
+ selectedHref + ", current: " + currentUrl
);
}
}

View File

@@ -1,33 +0,0 @@
package ru.kovbasa.tests;
import com.google.inject.Inject;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.Test;
import ru.kovbasa.config.GuiceExtension;
import ru.kovbasa.config.TestConfig;
import ru.kovbasa.pages.CatalogPage;
import ru.kovbasa.pages.CoursePage;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ExtendWith(GuiceExtension.class)
public class CourseSearchTest {
@Inject
private CatalogPage catalog;
@Test
void findCourseByName() {
catalog.open();
final String courseName = TestConfig.getCourseName();
final CoursePage page = catalog.clickCourseByName(courseName);
final String title = page.getCourseTitle();
assertTrue(
title.toLowerCase().contains(courseName.toLowerCase()),
"Course page title should contain searched course name"
);
}
}

View File

@@ -1,74 +0,0 @@
package ru.kovbasa.tests;
import com.google.inject.Inject;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.Test;
import ru.kovbasa.config.GuiceExtension;
import ru.kovbasa.pages.CatalogPage;
import ru.kovbasa.pages.CourseItem;
import ru.kovbasa.pages.CoursePage;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ExtendWith(GuiceExtension.class)
public class CoursesDatesTest {
@Inject
private CatalogPage catalog;
@Test
void earliestCourseHasCorrectTitleAndDate() {
catalog.open();
final List<CourseItem> earliestCourses = catalog.findEarliestCourses();
for (CourseItem course : earliestCourses) {
final CoursePage page = catalog.openCourse(course);
final String pageTitle = page.getCourseTitle();
assertTrue(
pageTitle.toLowerCase().contains(course.title().toLowerCase()),
"Earliest course title on page should contain title from catalog: " + course.title()
);
final LocalDate pageDate = page.getCourseStartDate(course.startDate());
assertEquals(
course.startDate(),
pageDate,
"Earliest course start date should match for course: " + course.title()
);
catalog.open();
}
}
@Test
void latestCourseHasCorrectTitleAndDate() {
catalog.open();
final List<CourseItem> latestCourses = catalog.findLatestCourses();
for (CourseItem course : latestCourses) {
final CoursePage page = catalog.openCourse(course);
final String pageTitle = page.getCourseTitle();
assertTrue(
pageTitle.toLowerCase().contains(course.title().toLowerCase()),
"Latest course title on page should contain title from catalog: " + course.title()
);
final LocalDate pageDate = page.getCourseStartDate(course.startDate());
assertEquals(
course.startDate(),
pageDate,
"Latest course start date should match for course: " + course.title()
);
catalog.open();
}
}
}

View File

@@ -0,0 +1,29 @@
# language: ru
Функция: Каталог курсов OTUS
Как пользователь
Я хочу работать с каталогом курсов через BDD
Чтобы проверять поиск, фильтрацию по датам и выбор по цене
Сценарий: Выбор браузера и поиск курса по названию со случайным выбором
Дано Открыт браузер Chrome
Когда Открыт каталог курсов
И Найден курс "Python" и выбран случайный из найденных
Тогда Заголовок страницы курса содержит "Python"
Сценарий: Поиск курсов, стартующих в указанную дату или позже
Дано Открыт браузер Chrome
Когда Открыт каталог курсов
И Найдены курсы со стартом "01.01.2025" или позже
Тогда В консоль выведены найденные курсы и даты старта
Сценарий: Поиск самого дорогого и самого дешевого подготовительного курса
Дано Открыт браузер Chrome
Когда Открыт раздел Обучение и Подготовительные курсы
И Выбраны самый дорогой и самый дешевый курсы с помощью filter
Тогда В консоль выведена информация о самом дорогом и самом дешевом курсе
Сценарий: Поиск самого дорогого и самого дешевого курса в каталоге по полной стоимости со скидкой
Дано Открыт браузер Chrome
Когда В каталоге курсов выбраны самый дорогой и самый дешевый курсы по полной стоимости со скидкой с помощью filter
Тогда В консоль выведена информация о самом дорогом и самом дешевом курсе в каталоге

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="io.github.bonigarcia.wdm" level="WARN"/>
<logger name="org.openqa.selenium" level="WARN"/>
<root level="WARN">
<appender-ref ref="STDOUT"/>
</root>
</configuration>

View File

@@ -0,0 +1,8 @@
handlers= java.util.logging.ConsoleHandler
.level= WARNING
java.util.logging.ConsoleHandler.level = SEVERE
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
org.openqa.selenium.devtools.level = SEVERE
org.openqa.selenium.devtools.CdpVersionFinder.level = SEVERE