Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# REPORT

## 1. Какой API выбран и почему

Я выбрал **GitHub REST API** (`https://api.github.com`), потому что этот API нужен мне для курсового проекта. Подробнее я описал в `api-notes.md`

Основной стек для реализации: REST Assured + JUnit 5 + AssertJ.

---

## 2. Покрытые сценарии

| Категория | Сценарий | Реализация |
|-----------|----------|------------|
| Happy path | Получение существующего пользователя `/users/octocat` | Реализовано |
| Happy path | Получение существующего репозитория `/repos/{owner}/{repo}` | Реализовано |
| Контракт ответа | Проверка обязательных полей и типов у профиля пользователя | Реализовано |
| Контракт ответа | Проверка полей репозитория (`id`, `name`, `full_name`, `owner.login`, `private`, `html_url`) | Реализовано |
| Границы/особенности | Проверка логина без учёта регистра (`/users/OCTOCAT`) | Реализовано |
| Параметры запроса | Проверка `per_page=2` и пагинации для `/users/{username}/repos` | Реализовано |
| Контракт ответа | Проверка заголовков `Cache-Control`, `ETag`, `X-GitHub-Api-Version-Selected`, `X-RateLimit-Limit` | Реализовано |
| Happy path / контракт | Проверка структуры `/repos/{owner}/{repo}/languages` | Реализовано |
| Ошибки клиента | Несуществующий пользователь возвращает `404` и тело ошибки | Реализовано |
| Ошибки клиента | Несуществующий репозиторий возвращает `404` и тело ошибки | Реализовано |
| Перформанс | Базовый запрос к пользователю укладывается в порог времени | Реализовано |

Итого в проекте:

- **9 тестовых методов**
- **11 выполнений тестов** с учётом 3 запусков параметризованного сценария
- покрыто **4 эндпоинта**:
- `GET /users/{username}`
- `GET /users/{username}/repos`
- `GET /repos/{owner}/{repo}`
- `GET /repos/{owner}/{repo}/languages`
- есть **параметризованный тест**
- есть **негативные сценарии** на `404`

---

## 3. Что удалось узнать об API

### 3.1. Полезные особенности GitHub API

- Для большинства запросов рекомендуется передавать:
- `Accept: application/vnd.github+json`
- `User-Agent`
- `X-GitHub-Api-Version`
- Публичные данные можно читать **без аутентификации**.
- Для неаутентифицированных запросов действует лимит **60 запросов в час**.
- Для search-эндпоинтов лимиты строже, поэтому их лучше не перегружать тестами.

### 3.2. Неожиданные или важные наблюдения

- `username` на практике обрабатывается **без учёта регистра**: `/users/OCTOCAT` возвращает `octocat`.
- Ошибка `404` имеет достаточно стабильный формат:

```json
{
"message": "Not Found",
"documentation_url": "https://docs.github.com/rest",
"status": "404"
}
```

- Ответы содержат полезные служебные заголовки:
- `ETag`
- `Cache-Control`
- `X-RateLimit-*`
- `X-GitHub-Api-Version-Selected`
- Эндпоинт `/languages` возвращает не массив, а JSON-чик, где ключ - язык, значение - число байтов.
- У некоторых репозиториев `/languages` может вернуть пустой объект `{}`.

### 3.3. Неоднозначности документации

- Документация GitHub очень подробная, но она огромная, и в ней легко потеряться.
- На практике некоторые поведенческие детали удобнее подтвердить экспериментом через `curl`, чем искать в справке, например:
- чувствительность к регистру,
- реальный формат ошибки,
- конкретные заголовки кэширования и rate limit.

---

## 4. Инструмент: что использовал, что понравилось и не понравилось

### Использованный инструмент

Я использовал:

- **REST Assured** - для HTTP-запросов и извлечения ответа
- **JUnit 5** - для организации тестов
- **AssertJ** - для читаемых и гибких проверок

### Что понравилось

- У REST Assured короткий и понятный DSL для API-тестов.
- Удобно задавать базовые заголовки через `RequestSpecification`.
- AssertJ делает проверки читаемыми: легко сравнивать поля, коллекции, заголовки и типы значений.
- `@ParameterizedTest` хорошо подходит для проверки нескольких репозиториев без дублирования кода.

### Что не понравилось / ограничения

- Тесты к публичному интернет-API всегда немного более хрупкие, чем тесты к локально замоканному сервису:
- возможны сетевые задержки,
- меняются реальные данные,
- есть rate limit

---

## 5. Запуск

Команда для прогона всех тестов:

```bash
mvn test
```

Или вот так лучше будет:

```bash
mvn -Dtest=GitHubApiTest test
```

Но это надо как-то обойти, что в репозитории есть файлы, которые не компилируются, поэтому проще ручками сходить в директорию с тестом и из IDE его запустить
9 changes: 8 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
<!-- REST Assured для интеграционных HTTP-тестов -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.5.0</version>
<scope>test</scope>
</dependency>
<!-- Source: https://mvnrepository.com/artifact/com.google.code.gson/gson -->
<dependency>
<groupId>com.google.code.gson</groupId>
Expand Down Expand Up @@ -111,4 +118,4 @@
</plugins>
</build>

</project>
</project>
184 changes: 184 additions & 0 deletions src/test/java/hse/java/githubapi/GitHubApiTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package hse.java.githubapi;

import io.restassured.RestAssured;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.path.json.JsonPath;
import io.restassured.response.Response;
import io.restassured.specification.RequestSpecification;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;

class GitHubApiTest {

private static final String BASE_URI = "https://api.github.com";
private static final String API_VERSION = "2026-03-10";
private static RequestSpecification requestSpecification;

@BeforeAll
static void setUp() {
RestAssured.baseURI = BASE_URI;
requestSpecification = new RequestSpecBuilder()
.setBaseUri(BASE_URI)
.addHeader("Accept", "application/vnd.github+json")
.addHeader("User-Agent", "course-project-github-api-tests")
.addHeader("X-GitHub-Api-Version", API_VERSION)
.build();
}

@ParameterizedTest(name = "GET /repos/{0}/{1} возвращает публичный репозиторий")
@MethodSource("knownRepositories")
void knownRepositoriesShouldBeAvailable(String owner, String repo) {
Response response = get("/repos/{owner}/{repo}", owner, repo);
JsonPath json = response.jsonPath();

assertThat(response.statusCode()).isEqualTo(200);
assertThat(json.getInt("id")).isPositive();
assertThat(json.getString("name")).isEqualTo(repo);
assertThat(json.getString("owner.login")).isEqualToIgnoringCase(owner);
assertThat(json.getString("full_name")).isEqualTo(owner + "/" + repo);
assertThat(json.getBoolean("private")).isFalse();
assertThat(json.getString("html_url")).isEqualTo("https://github.com/" + owner + "/" + repo);
}

@Test
void getUserShouldReturnRequiredPublicProfileFields() {
Response response = get("/users/{username}", "octocat");
JsonPath json = response.jsonPath();

assertThat(response.statusCode()).isEqualTo(200);
assertThat(json.getString("login")).isEqualTo("octocat");
assertThat(json.getInt("id")).isPositive();
assertThat(json.getString("type")).isEqualTo("User");
assertThat(json.getBoolean("site_admin")).isFalse();
assertThat(json.getString("html_url")).isEqualTo("https://github.com/octocat");
assertThat(json.getString("followers_url")).contains("/users/octocat/followers");
assertThat(json.getInt("public_repos")).isGreaterThanOrEqualTo(0);
assertThatCode(() -> Instant.parse(json.getString("created_at"))).doesNotThrowAnyException();
assertThatCode(() -> Instant.parse(json.getString("updated_at"))).doesNotThrowAnyException();
}

@Test
@DisplayName("Логин пользователя обрабатывается без учёта регистра")
void getUserShouldBeCaseInsensitiveForLogin() {
Response response = get("/users/{username}", "OCTOCAT");
JsonPath json = response.jsonPath();

assertThat(response.statusCode()).isEqualTo(200);
assertThat(json.getString("login")).isEqualTo("octocat");
assertThat(json.getString("html_url")).isEqualTo("https://github.com/octocat");
}

@Test
void listUserRepositoriesShouldHonorPerPageParameter() {
Response response = given()
.spec(requestSpecification)
.queryParam("per_page", 2)
.queryParam("sort", "updated")
.when()
.get("/users/{username}/repos", "octocat");

List<Map<String, Object>> repositories = response.jsonPath().getList("$");

assertThat(response.statusCode()).isEqualTo(200);
assertThat(repositories)
.isNotNull()
.hasSizeLessThanOrEqualTo(2)
.isNotEmpty();
assertThat(repositories)
.allSatisfy(repository -> {
assertThat(repository).containsKeys("id", "name", "full_name", "owner");
Map<String, Object> owner = (Map<String, Object>) repository.get("owner");
assertThat(owner.get("login")).isEqualTo("octocat");
});
assertThat(response.getHeader("Link"))
.as("При per_page=2 у octocat ожидается пагинация")
.contains("rel=\"next\"");
}

@Test
void getRepositoryLanguagesShouldReturnLanguageByteMap() {
Response response = get("/repos/{owner}/{repo}/languages", "torvalds", "linux");
Map<String, Object> languages = response.jsonPath().getMap("$");

assertThat(response.statusCode()).isEqualTo(200);
assertThat(languages).isNotNull().isNotEmpty().containsKey("C");
assertThat(languages.values())
.allSatisfy(value -> {
assertThat(value).isInstanceOf(Number.class);
assertThat(((Number) value).longValue()).isPositive();
});
}

@Test
void repositoryResponseShouldContainCachingAndVersionHeaders() {
Response response = get("/repos/{owner}/{repo}", "octocat", "Hello-World");

assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.getHeader("Content-Type")).contains("application/json");
assertThat(response.getHeader("Cache-Control")).contains("max-age");
assertThat(response.getHeader("ETag")).isNotBlank();
assertThat(response.getHeader("X-GitHub-Api-Version-Selected")).isEqualTo(API_VERSION);
assertThat(response.getHeader("X-RateLimit-Limit")).isNotBlank();
}

@Test
void getUnknownUserShouldReturn404AndStandardErrorBody() {
Response response = get("/users/{username}", "this-user-name-is-definitely-longer-than-thirty-nine-characters");
JsonPath json = response.jsonPath();

assertThat(response.statusCode()).isEqualTo(404);
assertThat(json.getString("message")).isEqualTo("Not Found");
assertThat(json.getString("documentation_url")).contains("docs.github.com/rest");
assertThat(json.getString("status")).isEqualTo("404");
}

@Test
void getUnknownRepositoryShouldReturn404AndStandardErrorBody() {
Response response = get("/repos/{owner}/{repo}", "octocat", "repo-that-should-not-exist-1234567890");
JsonPath json = response.jsonPath();

assertThat(response.statusCode()).isEqualTo(404);
assertThat(json.getString("message")).isEqualTo("Not Found");
assertThat(json.getString("documentation_url")).contains("docs.github.com/rest");
assertThat(json.getString("status")).isEqualTo("404");
}

@Test
void getUserShouldRespondWithinReasonableTime() {
Response response = get("/users/{username}", "octocat");

assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.time()).isLessThan(10_000L);
}

private static Stream<Arguments> knownRepositories() {
return Stream.of(
Arguments.of("octocat", "Hello-World"),
Arguments.of("torvalds", "linux"),
Arguments.of("github", "docs")
);
}

private Response get(String path, Object... pathParams) {
return given()
.spec(requestSpecification)
.when()
.get(path, pathParams)
.then()
.extract()
.response();
}
}
Loading