Testy obciążenia w Visual Studio  Udostępnij na: Facebook

Autor: Piotr Zieliński

Opublikowano: 2011-06-28

Wprowadzenie

Oprócz przetestowania czystej funkcjonalności aplikacji, należy zweryfikować również jej wydajność. W inżynierii oprogramowania wyróżniamy przynajmniej trzy typy testów związanych z optymalnością aplikacji: testy obciążenia, testy maksymalnego obciążenia oraz testy wydajnościowe.

Testy obciążenia sprawdzają, czy aplikacja jest w stanie obsłużyć wskazaną liczbę użytkowników. Zadaniem programisty jest zatem zasymulowanie odpowiedniego ruchu. Testy maksymalnego obciążenia mają za zadanie wyznaczyć maksymalne możliwości aplikacji. Wykonywane są podobnie jak testy obciążenia, z tym że symulowany jest ruch z wyższą częstotliwością. Tester zwiększa liczbę użytkowników aż do momentu, gdy aplikacja przestanie prawidłowo działać (zawiesi się np. z powodu wyczerpania zasobów). Z kolei testy wydajnościowe weryfikują, czy podana operacja jest wykonana we wskazanym czasie. Wykonanie testów obciążenia powinno składać się z kilku etapów:

Rysunek 1. Etapy tworzenia testu jednostkowego.

Przede wszystkim należy dokładnie zdefiniować obszar badań (wykonywania testów). Zwykle niemożliwe jest przetestowanie całego systemu. Architekci muszą określić, które miejsca są szczególnie wrażliwe na przyrost liczby użytkowników.
Kolejnym krokiem jest zdefiniowanie metryk odpowiedzialnych za ocenę jakościową systemu. Innymi słowy, są to kryteria, na podstawie których tester może stwierdzić, czy system przeszedł pozytywnie test obciążenia. Przykładowymi metrykami są: zużycie pamięci, wykorzystanie procesora, liczba wątków czy liczba wykonanych operacji IO.

Oprócz metryk konieczne jest również oszacowanie dopuszczalnych wartości dla konkretnej liczby użytkowników. Zwykle nie podaje się wyłącznie pojedynczej wartości, lecz przedział liczbowy określający wartości w trybie normalnej pracy (testy obciążenia) oraz podczas tzw. szczytów (peak).

Następnym naturalnym etapem jest zaimplementowanie konkretnych testów. Można wykorzystać już istniejące testy integracyjne, ponieważ testy obciążenia również mogą przekraczać granicę systemu, więc zasada ich konstrukcji jest podobna.

Po implementacji testów można przejść do symulacji użytkowników.

Ostatnim etapem jest analiza uzyskanych wartości.  Po wykonaniu eksperymentu tester określa, czy system spełnia wymogi jakościowe. W przypadku niespełnienia jakiegoś kryterium grupa odpowiedzialna za implementację systemu musi zidentyfikować problem. Proces ma charakter iteracyjny – po otrzymaniu poprawki testy są ponawiane, a następnie w przypadku dalszego niespełnienia wymogów przekazywane są do poprawy.

Architektura

W testach obciążeniach występuje tzw. kontroler oraz agent. Rolą kontrolera jest zarządzanie agentami. Z kolei agenci to komputery, które symulują ruch – wysyłają zapytania. Kontroler na początku wysyła testy do wszystkich agentów. Następnie, po otrzymaniu od nich potwierdzenia, wysyła rozkaz rozpoczęcia testów. Od tego momentu agenci wysyłają zapytania (wykonują testy). W artykule zostanie rozważony najprostszy przypadek, gdy Visual Studio, kontroler oraz agent są zainstalowane na jednym komputerze:

Rysunek 2. Architektura testu obciążenia rozważanego w artykule.

Istnieje jednak możliwość rozproszonego wykonania testów:

Rysunek 3. Rozproszone wykonywanie testów.

Komputery I oraz II przeznaczone są dla testerów. Na nich testerzy konfigurują testy, kontrolery oraz agentów. Na komputerze III zainstalowany jest kontroler zarządzający agentami. Z kolei na pozostałych komputerach (IV i V) instalowani są agenci. Oczywiście na te czynności można przeznaczyć dowolną liczbę komputerów pełniących rolę agentów.

Wykonanie testu obciążenia

W pierwszej kolejności należy utworzyć zwykły projekt testowy (Testproject).

Rysunek 4. Najpierw należy utworzyć projekt dla testów.

 

Aby stworzyć test obciążenia, najpierw należy napisać dowolny inny typ testu – np. jednostkowy. Ze względu na to, że testy jednostkowe są najbardziej popularne, w artykule zostały wykorzystane właśnie one:

[TestClass()]
    public class BasicOperationsTest
    {
        private TestContext testContextInstance;

        /// <summary>
        ///Gets or sets the test context which provides
        ///information about and functionality for the current test run.
        ///</summary>
        public TestContext TestContext
        {
            get
            {
                return testContextInstance;
            }
            set
            {
                testContextInstance = value;
            }
        }
     
        /// <summary>
        ///A test for BasicOperations Constructor
        ///</summary>
        [TestMethod()]
        public void BasicOperationsConstructorTest()
        {
            BasicOperations target = new BasicOperations();            
        }

        /// <summary>
        ///A test for Add
        ///</summary>
        [DataSource("System.Data.Odbc", "Dsn=Excel Files;dbq=|DataDirectory|\\TestData.xlsx;defaultdir=.fjd;driverid=1046;maxbuffersize=2048;pagetimeout=5", "Sheet1$", DataAccessMethod.Sequential), DeploymentItem("TestProject1\\TestData.xlsx"), TestMethod]
        public void AddTest()
        {            
            BasicOperations target = new BasicOperations();
            int numberA = Int32.Parse(TestContext.DataRow[0].ToString());
            int numberB = Int32.Parse(TestContext.DataRow[1].ToString());
            int expected = Int32.Parse(TestContext.DataRow[2].ToString());
            int actual;
            actual = target.Add(numberA, numberB);
            Assert.AreEqual(expected, actual);            
        }

        /// <summary>
        ///A test for Divide
        ///</summary>
        [TestMethod()]
        public void DivideTest()
        {
            BasicOperations target = new BasicOperations();
            int numberA = 4; 
            int numberB = 2; 
            int expected = 2;
            int actual;
            actual = target.Divide(numberA, numberB);
            Assert.AreEqual(expected, actual);            
        }
        [TestMethod()]
        [ExpectedException(typeof(ArgumentOutOfRangeException))]
        public void DivideByZeroTest()
        {
            BasicOperations target = new BasicOperations();
            int numberA = 4;
            int numberB = 0;            
            int actual;
            actual = target.Divide(numberA, numberB);            
        }

        /// <summary>
        ///A test for Multiply
        ///</summary>
        [TestMethod()]
        public void MultiplyTest()
        {
            BasicOperations target = new BasicOperations(); 
            int numberA = 2; 
            int numberB = 5; 
            int expected = 10; 
            int actual;
            actual = target.Multiply(numberA, numberB);
            Assert.AreEqual(expected, actual);
        }

        /// <summary>
        ///A test for Subtract
        ///</summary>
        [TestMethod()]
        public void SubtractTest()
        {
            BasicOperations target = new BasicOperations(); 
            int numberA = 10; 
            int numberB = 3; 
            int expected = 7;
            int actual;
            actual = target.Subtract(numberA, numberB);
            Assert.AreEqual(expected, actual);            
        }
    }

Poszczególne etapy powyższych metod nie będą wyjaśniane, ponieważ temat wykracza poza ramy tego artykułu. 

Po implementacji testów jednostkowych można przejść do utworzenia testu obciążenia. W menu kontekstowym solucji wybieramy Add->Load Test. Zostanie wówczas uruchomiony kreator:

Rysunek 5. Kreator testu obciążenia.

Pierwszy ekran nie zawiera nic skomplikowanego – wyłącznie zwykłą informację. Przejdźmy więc dalej:

Rysunek 6. Podstawowe ustawienia testu obciążenia.

W oknie możemy wpisać nazwę scenariusza – przypadku użycia. Istotniejszą jednak kwestią są tzw. think times. Służą one do zasymulowania zachowań użytkowników. Oczywiście użytkownik, używając np. aplikację webową, potrzebuje trochę czasu pomiędzy wysłaniem kolejnych zapytań. W realnym środowisku zapytania od użytkownika nie są wysyłane jedno po drugim. „Think time” definiuje właśnie czas pomiędzy kolejnymi akcjami użytkownika. Do dyspozycji są trzy opcje:

  • „Use recorded think times” – wykorzystujące wspomniane opóźnienia na podstawie testów webowych.
  • „Use normal distribution centered on recorded think times” – również wykorzystuje „think times”, jednak zmodyfikowane zgodnie z rozkładem normalnym. W przeciwieństwie do poprzedniej opcji, opóźnienia nie są takie same jak w testach webowych.
  • „Do not use think times” – brak opóźnień. Powoduje to oczywiście największe obciążenie zasobów.

Można również ustawić czas pomiędzy rozpoczęciem kolejnych iteracji testowania.

Model obciążenia

Następnie należy zdefiniować model obciążenia:

Rysunek 7. Model obciążenia.

Model obciążenia określa, ilu użytkowników powinno zostać zasymulowanych. Tester ma do dyspozycji stałą liczbę użytkowników (Constant Load) lub zwiększającą się w czasie. Dla drugiego modelu należy zatem zdefiniować kilka właściwości:

  • „Start user count” – początkowa liczba użytkowników.
  • „Step duration” – czas trwania pojedynczego kroku, po którym liczba użytkowników zostanie zwiększona.
  • „Step user count” – liczba użytkowników zwiększana w każdym kroku.
  • „Maximum user count” – maksymalna liczba użytkowników, która nigdy nie zostanie przekroczona.

Model mieszania testów

Po kliknięciu Next pojawi się okno wyboru trybu mieszania testów. Do dyspozycji są cztery modele, które odpowiadają za kolejność wykonywania poszczególnych testów:

  • Model bazujący na całkowitej liczbie testów – definiowany jest rozkład prawdopodobieństwa określający częstotliwość wykonywania konkretnych testów. Za pomocą modelu można zdefiniować, które testy powinny być wykonywane częściej, a które rzadziej.

Rysunek 8. Model bazujący na całkowitej liczbie testów.

  • Model bazujący na całkowitej liczbie użytkowników. Model określa, ile procent różnych użytkowników  powinno wykonać konkretny test. Jeśli zdefiniowano wartość 75% dla testu A, oznacza to, że środowisko symulacyjne (Visual Studio 2010) będzie starało się,  aby 75% użytkowników wykonało ten test.

Rysunek 9. Model bazujący na całkowitej liczbie użytkowników.

  • Model bazujący na częstotliwości użytkowników – każdy test uruchamiany jest określoną liczbę razy w ciągu godziny.

Rysunek 10. Model bazujący na częstotliwości użytkowników.

  • Ostatni model polega na uruchomieniu testów przez każdego użytkownika w kolejności zdefiniowanej w scenariuszu przez testera.

Rysunek 11. Mode sekwencyjny.

Mieszanie testów

Następnym krokiem jest wybranie testów, które będą wywoływane. Warto wspomnieć, że tester ma do dyspozycji wszystkie testy wspierane przez Visual Studio. Nie muszą to być wyłącznie testy jednostkowe, ale również np. testy webowe.

Rysunek 12. Wybieranie oraz określanie częstotliwości testów.

Oprócz wybrania testów można również określić ich częstotliwość występowania. Na powyższym screenie wszystkie testy mają ustawioną częstotliwość na 20%, a zatem prawdopodobieństwo wystąpienia któregoś z nich jest jednakowe.

Konfiguracja interfejsu dostępowego (network mix).

Intensywność komunikacji z zasobami zależy również od wykorzystywanego łącza. Wysyłanie zapytań w sieci lokalnej LAN  znacznie bardziej obciąża zasoby, niż połączenie DSL czy 3G. Visual Studio pozwala na dokładną konfiguracje interfejsów, za pomocą których symulowane są zapytania:

Rysunek 13. Konfiguracja interfejsów sieciowych.

Należy zaznaczyć, że konfiguracja kilku interfejsów sieciowych jest możliwa i ma wyłącznie sens wtedy, gdy w scenariuszu zawarte są wyłącznie testy webowe. Dla rozważanych testów jednostkowych powinno zaznaczyć się tylko interfejs LAN.

Liczniki wydajności

Kolejnym krokiem jest konfiguracja liczników badających różne parametry wydajnościowe.

Rysunek 14. Konfiguracja liczników.

Domyślnie został utworzony kontroler oraz agent (obydwa znajdują się na lokalnym komputerze). Można jednak dodać dodatkowy komputer znajdujący się w sieci za pomocą przycisku Add Computer. W przypadku aplikacji rozproszonych (działających na kilku komputerach) takie rozwiązanie ma sens. W artykule prezentowany jest jednak prosty kod wykonywany lokalnie na komputerze, więc nie ma potrzeby dodawania dodatkowych węzłów.

Parametry uruchomieniowe

W ostatnim oknie można określić parametry uruchomieniowe.

Rysunek 15. Konfiguracja parametrów uruchomieniowych.

Przede wszystkim można skonfigurować czas trwania testu obciążenia – czas inicjalizacji oraz czas wykonania. Jeśli tester nie chce określać długości testu w jednostce czasu, może zdefiniować maksymalną liczbę iteracji. W części „szczegóły” można zdefiniować częstotliwość próbkowania (pobierania danych z liczników), opis oraz wykonywanie logów.

Przechowywanie zebranych danych

Dane zebrane podczas symulacji muszą oczywiście gdzieś zostać zapisane. Wszystkie informacje (wartości liczników, informacje o błędach) można zapisać w bazie danych.

Należy najpierw stworzyć odpowiednią bazę, w której będą mogły być zapisane dane. Do tego służą, na szczęście, gotowe narzędzia:

  1. otwieramy wiersz poleceń Visual Studio Command prompt,
  2. przechodzimy do odpowiedniego katalogu za pomocą:
    cd n:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE .
    Ścieżka może być inna, w zależności od zainstalowanej wersji VS (64- lub 32-bitowa) oraz lokalizacji, w której jest zainstalowany,
  3. wywołujemy skrypt tworzący wymaganą strukturę:
    SQLCMD /S (local) -i loadtestresultsrepository.sql.

Baza oraz wszelkie wymagane tabele zostały utworzone. Aby się o tym przekonać, można uruchomić SQL Management Studio i sprawdzić, czy istnieje baza o nazwie LoadTest2010.

Następnie należy wskazać lokalizację bazy w VS. W tym celu z głównego menu należy wybrać Test->Manage Test Controllers, a następnie w polu Load Test Result Store wybrać poprawny connection string:

Rysunek 16. Konfiguracja repozytorium.

Baza danych jest dobrym rozwiązaniem, szczególnie gdy wykorzystujemy rozproszone testy – takie, w których bierze udział kilka komputerów jednocześnie. Wtedy oczywiście niezbędne jest wskazanie wspólnego miejsca do przechowywania wszelkich logów.

Uruchomienie testów

Aby uruchomić test, należy najpierw kliknąć dwukrotnie na ikonce testu w Solution Explorer. Pojawi się okno podsumowujące konfigurację testu:

Rysunek 17. Konfiguracja testu.

W tym oknie można również modyfikować wszelkie parametry zdefiniowane w poprzednich krokach. Przedstawiony wcześniej kreator ma za zadanie po prostu ułatwić konfigurację testu. Można jednak samodzielnie i bezpośrednio modyfikować parametry za pomocą tego okna. W celu uruchomienia testu wystarczy kliknąć ikonkę zielonej strzałki (Run Tests) – pojawi się okno przedstawiające postęp wykonywania:

Rysunek 18. Wykonywanie testu.

Po pewnym czasie (w zależności od konfiguracji) wyświetli się okno podsumowujące wyniki testu:

Rysunek 19. Podsumowanie wykonania testu.

Do dyspozycji są również widoki: graficzny, tabelaryczny oraz szczegółowy. W widoku graficznym można obejrzeć m.in. wartości różnych liczników za pomocą tabeli oraz wykresu:

Rysunek 20. Widok graficzny.

Gdyby któraś z metryk (wartości licznika) została przekroczona, na wykresie zostałoby to stosownie oznaczone oraz wyraźnie wskazane w podsumowaniu.

Podsumowanie

Testy obciążenia pozwalają na zweryfikowanie wydajności kodu. Ze względu na równoległe wywoływanie metod testy również umożliwiają sprawdzenie, czy zaimplementowana funkcjonalność działa prawidłowo w środowisku współbieżnym. Głównym jednak zadaniem testów obciążenia jest estymacja wymagań sprzętowych oraz lokalizacja fragmentów aplikacji podatnych na wzrost obciążenia.

Kod źródłowy zawarty w artykule można znaleźć tu.

 


          

Piotr Zieliński

Absolwent informatyki o specjalizacji inżynieria oprogramowania Uniwersytetu Zielonogórskiego. Posiada szereg certyfikatów z technologii Microsoft (MCP, MCTS, MCPD). W 2011 roku wyróżniony nagrodą MVP w kategorii Visual C#. Aktualnie pracuje w General Electric pisząc oprogramowanie wykorzystywane w monitorowaniu transformatorów . Platformę .NET zna od wersji 1.1 – wcześniej wykorzystywał głównie MFC oraz C++ Builder. Interesuje się wieloma technologiami m.in. ASP.NET MVC, WPF, PRISM, WCF, WCF Data Services, WWF, Azure, Silverlight, WCF RIA Services, XNA, Entity Framework, nHibernate. Oprócz czystych technologii zajmuje się również wzorcami projektowymi, bezpieczeństwem aplikacji webowych i testowaniem oprogramowania od strony programisty. W wolnych chwilach prowadzi blog o .NET i tzw. patterns & practices.