Разделы портала

Онлайн-тренинги

.
Рефакторинг примеров тестов RestSharp для улучшения поддерживаемости
02.07.2020 00:00

Автор: Хиллари Уивер-Робб (Hillary Weaver-Robb)
Оригинал статьи
Перевод: Ольга Алифанова

Я хотела провести рефакторинг некоторых примеров своих тестов, используя RestSharp и NUnit (например, тестов из этих статей). Для разовых акций это отличные примеры API-тестов, но когда вы их объединяете, то получаете неподдерживаемую свалку, нарушающую множество принципов разработки ПО. Если тест-код не соответствует тем же принципам и практикам, что и код приложения – на него легко махнуть рукой как на "ненастоящий код", но это тоже код, и он важен!

Мы будем работать с той же базой кода, что и в прочих моих примерах – конкретнее, с веткой RestSharp тестов API (см. тут). Я буду приводить код тестов прямо тут, поэтому вам не придется обращаться к репозиторию, но на всякий случай у вас есть ссылка.

Посмотрим, с чего мы начинаем (взято по ссылке выше, без ряда тестов простоты ради):

  1. using NUnit.Framework;
  2. using RestSharp;
  3. using System;
  4. using System.Collections;
  5. using System.Collections.Generic;
  6. using TodoApi.Models;
  7. namespace TodoApiTests
  8. {
  9. [TestFixture]
  10. public class TodoIntegrationTests
  11. {
  12. private static string _baseUrl;
  13. [OneTimeSetUp]
  14. public void TestClassInitialize()
  15. {
  16. //убедитесь, что порт верный!
  17. _baseUrl = "https://localhost:44350/api/Todo/";
  18. }
  19. [Test]
  20. public void VerifyGetReturns200Ok()
  21. {
  22. //Подготовка
  23. var client = new RestClient(_baseUrl);
  24. var request = new RestRequest(Method.GET);
  25. //Действие
  26. IRestResponse response = client.Execute(request);
  27. //Проверка
  28. Assert.IsTrue(response.IsSuccessful, "Get method did not return a success status code; it returned " + response.StatusCode);
  29. }
  30. [Test]
  31. public void VerifyGetTodoItem1ReturnsCorrectName()
  32. {
  33. // Подготовка
  34. var expectedName = "Walk the dog"; //мы знаем, что это должно быть так, из конструктора Controller
  35. var client = new RestClient(_baseUrl);
  36. var request = new RestRequest("1", Method.GET); //наш URL выглядит так: https://localhost:44350/api/Todo/1
  37. // Действие
  38. IRestResponse<TodoItem> actualTodo = client.Execute<TodoItem>(request);
  39. // Проверка
  40. Assert.AreEqual(expectedName, actualTodo.Data.Name, "Expected and actual names are different. Expected " + expectedName + " but was " + actualTodo.Data.Name);
  41. }
  42. [Test]
  43. public void VerifyPostingTodoItemPostsTheItem()
  44. {
  45. // Подготовка
  46. TodoItem expItem = new TodoItem
  47. {
  48. Name = "mow the lawn",
  49. DateDue = new DateTime(2019, 12, 31),
  50. IsComplete = false
  51. };
  52. var client = new RestClient(_baseUrl);
  53. var postRequest = new RestRequest(Method.POST);
  54. postRequest.RequestFormat = DataFormat.Json;
  55. postRequest.AddJsonBody(expItem);
  56. // Действие
  57. //сначала нужно выполнить действие POST и получить ID нового объекта
  58. IRestResponse<TodoItem> postTodo = client.Execute<TodoItem>(postRequest);
  59. var newItemId = postTodo.Data.Id; //нам нужен ID для выполнения GET-запроса этого предмета
  60. //теперь нам нужно выполнить GET-запрос, используя ID нового объекта
  61. var getRequest = new RestRequest(newItemId.ToString(), Method.GET);
  62. IRestResponse<TodoItem> getTodo = client.Execute<TodoItem>(getRequest);
  63. // Проверка
  64. Assert.AreEqual(expItem.Name, getTodo.Data.Name, "Item Names are not the same, expected " + expItem.Name + " but got " + getTodo.Data.Name);
  65. Assert.AreEqual(expItem.DateDue, getTodo.Data.DateDue, "Item DateDue are not the same, expected " + expItem.DateDue + " but got " + getTodo.Data.DateDue);
  66. Assert.AreEqual(expItem.IsComplete, getTodo.Data.IsComplete, "Item IsComplete are not the same, expected " + expItem.IsComplete + " but got " + getTodo.Data.IsComplete);
  67. }
  68. }
  69. }

Всего лишь три теста – и много дубликатов, и тесты имеют дело с вещами, которые не должны их тревожить. Я вижу ряд мест, с которых можно начать, чтобы по-настоящему очистить наш код и сделать его куда более поддерживаемым (и нам не придется перепечатывать кучу всего раз за разом при создании новых тестов).

  1. Переместите инстанцирование RestClient в метод OneTimeSetUp – каждому тесту не нужно инстанцировать его! Когда он в методе OneTimeSetUp, он будет инициализироваться и использоваться повторно для каждого теста. Не знаю, вызовет ли это проблему с многопоточностью, одновременностью или чем-то еще. Если вы столкнулись с проблемами – попробуйте переместить его в метод SetUp, который будет вызываться перед запуском каждого теста.
  2. Создайте класс Helper с методами для каждого вызова API. Это не должно содержаться в каждом тесте – если у нас несколько тестов для одних и тех же вызовов, это приведет к куче дублирований, и поддерживаемость будет ужасной!
  3. Создайте метод Helper для инстанциации нового TodoItem. Нашим POST и PUT-тестам он понадобится – и, опять же, сами тесты не должны этим заниматься.

Шаги 1 и 2 идут рука об руку, поэтому выполним их совместно. Начнем с перемещения инстанциации RestClient на уровень выше.

Для этого добавим поле к классу для объекта RestClient, а затем – инстанциацию этого клиента в метод TestClassInitialize:

  1. namespace TodoApiTests
  2. {
  3. [TestFixture]
  4. public class Refactored_TodoIntegrationTests
  5. {
  6. private static string _baseUrl;
  7. private static RestClient _client;
  8. [OneTimeSetUp]
  9. public void TestClassInitialize()
  10. {
  11. //убедитесь, что порт верен!
  12. _baseUrl = "https://localhost:44350/api/Todo/";
  13. _client = new RestClient(_baseUrl);
  14. }
  15. ...
  16. }
  17. }

Прежде чем обновлять наши тесты для использования нового объекта _client, создадим новый класс Helper, который будет генерировать запросы, используемые _client.

Создайте новый файл класса, который я назову Helpers.cs. Первый метод, который я создам, будет рефакторить наш первый тест – это метод создания простого GET-запроса. Метод вернет объект RestRequest, наш базовый GET для всех TodoItems:

  1. using RestSharp;
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Text;
  5. namespace TodoApiTests
  6. {
  7. public static class Helpers
  8. {
  9. public static RestRequest GetAllTodoItemsRequest()
  10. {
  11. var request = new RestRequest(Method.GET);
  12. //request.AddHeader("CanAccess", "true"); //здесь можно добавить заголовок к запросу
  13. return request;
  14. }
  15. }
  16. }

При помощи этого нового метода я могу переработать мой первый тест для использования нового объекта _client и нового запроса. Я уберу инстанциацию клиента из секции // Подготовка, и обновлю запрос для использования метода Helper:

  1. [TestFixture]
  2. public class Refactored_TodoIntegrationTests
  3. {
  4. private static string _baseUrl;
  5. private static RestClient _client;
  6. [OneTimeSetUp]
  7. public void TestClassInitialize()
  8. {
  9. //убедитесь, что порт верен!
  10. _baseUrl = "https://localhost:44350/api/Todo/";
  11. _client = new RestClient(_baseUrl);
  12. }
  13. [Test]
  14. public void VerifyGetReturns200Ok()
  15. {
  16. //Подготовка
  17. var request = Helpers.GetAllTodoItemsRequest();
  18. //Действие
  19. IRestResponse response = _client.Execute(request);
  20. //Проверка
  21. Assert.IsTrue(response.IsSuccessful, "Get method did not return a success status code; it returned " + response.StatusCode);
  22. }
  23. ...
  24. }

Итого мы убрали из теста одну строчку. И это после всего, что мы сделали?! Мы добавили больше кода, чем убрали? Какой в этом смысл? Смысл в том, что теперь его легче поддерживать. Если нам понадобится добавить к запросам заголовки, не придется обновлять все тесты до единого – всего лишь внесем правки в метод Helper. Это проще увидеть при помощи других тестов. Давайте добавим и их в класс Helper!

  1. using RestSharp;
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Text;
  5. namespace TodoApiTests
  6. {
  7. public static class Helpers
  8. {
  9. public static RestRequest GetAllTodoItemsRequest()
  10. {
  11. var request = new RestRequest(Method.GET);
  1. //request.AddHeader("CanAccess", "true"); // здесь можно добавить заголовок к запросу
  1. return request;
  2. }
  3. public static RestRequest GetSingleTodoItemRequest(long id)
  4. {
  5. var request = new RestRequest($"{id}", Method.GET);
  6. request.AddUrlSegment("id", id);
  7. return request;
  8. }
  9. public static RestRequest PostTodoItemRequest(TodoItem item)
  10. {
  11. var request = new RestRequest(Method.POST);
  12. request.RequestFormat = DataFormat.Json;
  13. request.AddJsonBody(item);
  14. return request;
  15. }
  16. }
  17. }

Теперь у нас есть метод Helper для всех запросов, выполняемых тестами – GET для всех TodoItems, GET для единичного TodoItem, и POST для TodoItem. Очевидно, тут у нас не все возможные тесты, но оставлю это на следующий раз.

Заметьте, что в методе GetSingleTodoItemRequest нам нужно изменить URL. Это связано с тем, что мы уже настроили baseUrl, однако для получения единичного предмета нам нужен его ID, поэтому мы приписываем его к URL. Также отметьте, что в методе PostTodoItemRequest мы передаем предмет, и добавляем заголовки (в форме RequestFormat), а также добавляем предмет в формате JSON в тело запроса.

Вот так наши тесты будут выглядеть после рефакторинга с использованием нового объекта _client и методов Helper:

  1. using NUnit.Framework;
  2. using RestSharp;
  3. using System;
  4. using System.Collections;
  5. using System.Collections.Generic;
  6. using TodoApi.Models;
  7. namespace TodoApiTests
  8. {
  9. [TestFixture]
  10. public class Refactored_TodoIntegrationTests
  11. {
  12. private static string _baseUrl;
  13. private static RestClient _client;
  14. [OneTimeSetUp]
  15. public void TestClassInitialize()
  16. {
  17. //убедитесь, что порт верен!
  18. _baseUrl = "https://localhost:44350/api/Todo/";
  19. _client = new RestClient(_baseUrl);
  20. }
  21. [Test]
  22. public void VerifyGetReturns200Ok()
  23. {
  24. //Подготовка
  25. var request = Helpers.GetAllTodoItemsRequest();
  26. //Действие
  27. IRestResponse response = _client.Execute(request);
  28. //Проверка
  29. Assert.IsTrue(response.IsSuccessful, "Get method did not return a success status code; it returned " + response.StatusCode);
  30. }
  31. [Test]
  32. public void VerifyGetTodoItem1ReturnsCorrectName()
  33. {
  34. // Подготовка
  1. var expectedName = "Walk the dog"; // мы знаем, что это должно быть так, из конструктора Controller
  1. var request = Helpers.GetSingleTodoItemRequest(1);
  2. // Действие
  3. IRestResponse<TodoItem> actualTodo = _client.Execute<TodoItem>(request);
  4. // Проверка
  5. Assert.AreEqual(expectedName, actualTodo.Data.Name, "Expected and actual names are different. Expected " + expectedName + " but was " + actualTodo.Data.Name);
  6. }
  7. [Test]
  8. public void VerifyPostingTodoItemPostsTheItem()
  9. {
  10. //Подготовка
  11. TodoItem expItem = new TodoItem
  12. {
  13. Name = "mow the lawn",
  14. DateDue = new DateTime(2019, 12, 31),
  15. IsComplete = false
  16. };
  17. var postRequest = Helpers.PostTodoItemRequest(expItem);
  18. //Действие
  19. // сначала нужно выполнить действие POST и получить ID нового объекта
  20. IRestResponse<TodoItem> postTodo = _client.Execute<TodoItem>(postRequest);
  1. var newItemId = postTodo.Data.Id; // нам нужен ID для выполнения GET-запроса этого предмета
  1. // теперь нам нужно выполнить GET-запрос, используя ID нового объекта
  2. var getRequest = Helpers.GetSingleTodoItemRequest(newItemId);
  3. IRestResponse<TodoItem> getTodo = _client.Execute<TodoItem>(getRequest);
  4. //Проверка
  5. Assert.AreEqual(expItem.Name, getTodo.Data.Name, "Item Names are not the same, expected " + expItem.Name + " but got " + getTodo.Data.Name);
  6. Assert.AreEqual(expItem.DateDue, getTodo.Data.DateDue, "Item DateDue are not the same, expected " + expItem.DateDue + " but got " + getTodo.Data.DateDue);
  7. Assert.AreEqual(expItem.IsComplete, getTodo.Data.IsComplete, "Item IsComplete are not the same, expected " + expItem.IsComplete + " but got " + getTodo.Data.IsComplete);
  8. }
  9. }
  10. }

Мы снова удалили всего лишь одну строчку кода из теста GET на единичный TodoItem, однако мы удалили 3 из теста POST. И мы сможем удалить еще больше, выполнив третий шаг рефакторинга – создав метод Helper для инстанциации TodoItem! Давайте это сделаем.

Этот метод Helper должен возвращать TodoItem по умолчанию, который можно использовать, если точные значения не особенно важны для теста. Однако нам также нужна возможность передавать значения для использования, если это важно. Вот что получилось с методом Helper у меня:

  1. public static TodoItem GetTestTodoItem(string name = "mow the lawn", bool isCompleted = false, DateTime dateDue = default(DateTime))
  2. {
  3. if (dateDue == default(DateTime))
  4. {
  5. dateDue = new DateTime(2029, 12, 31);
  6. }
  7. return new TodoItem
  8. {
  9. Name = name,
  10. DateDue = dateDue,
  11. IsComplete = isCompleted
  12. };
  13. }

Он возвращает объект TodoItem, а подпись метода по сути говорит "Если Name, IsCompleted, или DateDue не предоставлены, будем использовать значения по умолчанию". Для установки даты по умолчанию придется прибегнуть к небольшой хитрости. Это краткосрочное решение, так как для корректной работы значение DateDue должно быть датой в будущем. Мы можем написать код, чтобы использовалась, допустим, дата через две недели от текущей. Эта идея получше моей – моя, по сути, часовая бомба замедленного действия. Еще один забавный баг, который я оставлю в этом тест-приложении!

В любом случае теперь мы можем использовать TodoItem по умолчанию где угодно, и убрать еще больше кода из теста POST:

  1. [Test]
  2. public void VerifyPostingTodoItemPostsTheItem()
  3. {
  4. //Подготовка
  5. var expItem = Helpers.GetTestTodoItem();
  6. var postRequest = Helpers.PostTodoItemRequest(expItem);
  7. //Действие
  8. // сначала нужно выполнить действие POST и получить ID нового объекта
  9. IRestResponse<TodoItem> postTodo = _client.Execute<TodoItem>(postRequest);
  1. var newItemId = postTodo.Data.Id; // нам нужен ID для выполнения GET-запроса этого предмета
  1. // теперь нам нужно выполнить GET-запрос, используя ID нового объекта
  2. var getRequest = Helpers.GetSingleTodoItemRequest(newItemId);
  3. IRestResponse<TodoItem> getTodo = _client.Execute<TodoItem>(getRequest);
  4. //Проверка
  5. Assert.AreEqual(expItem.Name, getTodo.Data.Name, "Item Names are not the same, expected " + expItem.Name + " but got " + getTodo.Data.Name);
  6. Assert.AreEqual(expItem.DateDue, getTodo.Data.DateDue, "Item DateDue are not the same, expected " + expItem.DateDue + " but got " + getTodo.Data.DateDue);
  7. Assert.AreEqual(expItem.IsComplete, getTodo.Data.IsComplete, "Item IsComplete are not the same, expected " + expItem.IsComplete + " but got " + getTodo.Data.IsComplete);
  8. }

Мы убрали секцию создания предмета и заменили ее получением предмета через Helper мы оставили ее как свою собственную инстанциацию var, потому что далее мы используем этот предмет в проверках.

Итак, мы почистили код, переместили часть кода в методы Helper, и немного улучшили дизайн наших тестов. Финальный результат можно увидеть в ветке RestSharp-Refactor репозитория по ссылке.

Как бы поступили вы? Каким еще способом можно провести рефакторинг этих тестов?

Обсудить в форуме