GNOME3ApplicationDevelopmentBeginnersGuide:Chapter 12

From 흡혈양파의 번역工房
Jump to navigation Jump to search
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.
제 12 장 품질이 좋으면 모든 게 쉬워진다

품질이 좋으면 모든 게 쉬워진다

소프트웨어 품질은 제품을 시장으로 발표하기 전에 확인하는 것이 아니다. 품질의 확인은 그보다 훨씬 전에 이루어진다. 많은 소프트웨어 개발자들은 한 조각의 코드가 쓰여지기도 전에 품질을 확인하기도 한다. 테스트를 거치고 잘 작성됨과 동시 잘 관리된 코드와 잘 정의된 규칙 집합은 소프트웨어 개발에 필수적이다. 이 모두를 GNOME 개발 환경에서 어떻게 실현할 수 있을까? 한 번 알아보자.


소프트웨어에 필요한 여러 테스팅 중에서 단위 테스팅은 종종 쓰기가 까다롭다. 따라서 이번 장에서는 단위 테스팅의 실행을 집중적으로 살펴볼 것이다. GLib, GTK+, Gdk가 제공하는 테스팅 프레임워크를 사용할 예정이다. 본 장에서는 테스팅을 쉽게 자동화하는 방법에 관한 통찰력을 얻을 수 있도록 Anjuta 대신 명령행을 직접적으로 사용할 것이다. 이번 장에 실린 활동에서는 앞 장의 여러 장에 걸쳐 사용된 코드를 사용하고 그에 단위 테스팅을 추가하고자 한다.


구체적으로 다룰 내용은 다음과 같다.

  • 단위 테스팅의 개념
  • 라이브러리 스터빙(stubbing)
  • GUI 모듈 테스트하기


그럼 본격적으로 시작해보자.


단위 테스팅을 실행하는 이유

단위 테스트는 소스 코드에서 특정 객체를 대상으로 실행되는 구체적인 테스트로서 국소화되어 실행된다. 테스트가 각 기능을 실제로 다루는지 확실히 할 필요가 있다. 단위 테스팅은 아래와 같이 간단하게 이루어진다.

  • 각 함수를 테스트한다.
  • 함수 내 각 branch를 테스트한다. 함수에 if와 else문, 또는 switch와 case문이 포함된 경우를 예로 들 수 있겠다.
  • 앞의 규칙들은 소스 코드에서 모든 장소를 확보하여 프로그램을 소비자에게 전달할 때 놀랄만한 요소, 특히 바람직하지 못한 요소가 기다리고 있지 않도록 확보하기 위함이다.
  • 함수를 테스트하기 위해서는 인자 리스트에 데이터를 전달할 필요가 있다. 우선 데이터를 만들고, 필요하다면 두 번째 규칙이 충족되도록 여러 데이터 집합을 이용해 테스트를 반복한다.
  • 단위 테스팅은 코드 조각을 쓸 때마다 실행된다. 많은 소프트웨어 개발자들은 코드를 쓰기도 전에 단위 테스트를 생성함으로써 "Test-Driven Development(TDD)"(테스트 주도 개발) 방법론을 사용한다! 이런 경우 코드로부터 예상되는 내용을 이미 알고 있기 때문에 어떤 함수를 전달해야 하는지에 초점을 둘 수 있다는 장점이 있다. 이는 특히 라이브러리를 작성할 때 중요한데, 단위 테스트를 먼저 생성하여 API가 어떻게 생겼는지와 어떻게 사용할 것인지를 정확히 알 수 있기 때문이다.
  • 단위 테스팅은 결정적(deterministic)이어야 하는데, 이는 특정 입력을 제공함으로써 출력이 무엇인지 정확히 알아야 한다는 의미다. 이를 성공적으로 구현하기 위해서는 테스팅에 데이터를 무작위로 집어넣어선 안 되며, 우리가 사용하는 API의 행위 역시 결정적이어야 한다.


실행하기 - 첫 번째 단위 테스트 생성하기

첫 활동으로, 맨 처음으로 Vala를 다루었던 코드인 제 3장, 프로그래밍 언어의 Vala 알아가기 절에서 소개한 hello-vala 프로그램을 살펴보자. 단위 테스트를 생성하게 될 두 개의 객체, Book과 Bookstore가 있다.

  1. Anjuta에서 hello_vala.anjuta를 다시 열어보자.
  2. 프로젝트의 최상위 디렉터리에서 configure.ac 파일을 열어라. 아래 코드에 표시된 출력 부분을 찾아라.
    AC_OUTPUT([
    Makefile
    src/Makefile
    tests/Makefile
    ])
    
  3. 아래 코드와 같이 변경하라.
    AC_OUTPUT([
    Makefile
    src/Makefile
    tests/Makefile
    ])
    
  4. 프로젝트 최상위 디렉터리에서 Makefile.am 파일을 열고 SUBDIRS 부분을 찾아라.
    SUBDIRS = src
    
  5. 아래 코드와 같이 변경하라.
    SUBDIRS = src tests
    
  6. 최상위 디렉터리에 tests라는 디렉터리를 생성하라.
  7. tests 디렉터리에 새로운 Makefile.am 파일을 생성하고 아래의 코드로 채워라.
    AM_CPPFLAGS = \
        -DPACKAGE_LOCALE_DIR=\""$(localedir)"\" \
        -DPACKAGE_SRC_DIR=\""$(srcdir)"\" \
        -DPACKAGE_DATA_DIR=\""$(pkgdatadir)"\" \
        $(HELLO_VALA_CFLAGS) \
        $(atk_CFLAGS) \
        $(gee-1.0_CFLAGS)
    AM_CFLAGS =\
        -Wall\
        -g
    TESTS=test_book test_bookstore
    check_PROGRAMS = test_book test_bookstore
    test_book_SOURCES = \
        test_book.vala ../src/book.vala
    test_book_VALAFLAGS = \
        --pkg gtk+-3.0 \
        --pkg gee-1.0
    test_book_LDFLAGS = \
        -Wl,--export-dynamic
    test_book_LDADD = $(HELLO_VALA_LIBS) \
        $(atk_LIBS) \
        $(gee-1.0_LIBS)
    test_bookstore_SOURCES = \
        test_bookstore.vala ../src/book.vala ../src/bookstore.vala
    test_bookstore_VALAFLAGS = \
        --pkg gtk+-3.0 \
        --pkg gee-1.0
    test_bookstore_LDFLAGS = \
        -Wl,--export-dynamic
    test_bookstore_LDADD = $(HELLO_VALA_LIBS) \
        $(atk_LIBS) \
        $(gee-1.0_LIBS)
    
  8. tests 디렉터리에 test_book.vala라는 새로운 Vala 코드를 생성하고 아래의 코드로 채워라.
    public class TestBook {
        static void test_isbn ()
        {
            var b = new Book("1", "title");
            assert(b.isbn == "1");
        }
        static void test_title ()
        {
            var b = new Book("1", "title");
            assert(b.title == "title");
        }
        static void test_add_author()
        {
            var b = new Book("1", "title");
            b.addAuthor("author1");
            b.addAuthor("author2");
            b.addAuthor("author3");
            assert(b.authors.size == 3);
        }
        static int main (string[] args)
        {
            Test.init (ref args);
            Test.add_func ("/test-isbn", test_isbn);
            Test.add_func ("/test-title", test_title);
            Test.add_func ("/test-add-author", test_add_author);
            Test.run ();
            return 0;
        }
    }
    
  9. test_bookstore.vala라는 새로운 Vala 파일을 생성하고 tests 디렉터리에 넣어라. 아래 코드를 파일로 복사하라.
    public class TestBookStore {
        static void test_add_stock()
        {
            var b = new Book("1", "title");
            var s = new BookStore(b, 1.0, 12);
            s.addStock(13);
            assert(s.getStock() == 25);
            assert(s.isAvailable() == true);
        }
        static void test_remove_stock()
        {
            var b = new Book("1", "title");
            var s = new BookStore(b, 1.0, 12);
            s.addStock(13);
            s.removeStock(10);
            assert(s.getStock() == 15);
            assert(s.isAvailable() == true);
        }
        static void test_stock_alert()
        {
            var b = new Book("1", "title");
            var s = new BookStore(b, 1.0, 12);
            var alert_emitted = false;
            s.stockAlert.connect(() => {
                alert_emitted = true;
            });
            s.removeStock(1);
            assert(alert_emitted == false);
            s.removeStock(10);
            assert(alert_emitted == true);
        }
        static void test_price_alert()
        {
            var b = new Book("1", "title");
            var s = new BookStore(b, 1.0, 12);
            var alert_emitted = false;
            s.priceAlert.connect(() => {
                alert_emitted = true;
            });
            s.setPrice(2.5);
            assert(alert_emitted == false);
            s.setPrice(0.5);
            assert(alert_emitted == true);
        }
        static int main (string[] args)
        {
            Test.init (ref args);
            Test.add_func ("/test-add-stock", test_add_stock);
            Test.add_func ("/test-remove-stock", test_remove_stock);
            Test.add_func ("/test-stock-alert", test_stock_alert);
            Test.run ();
            return 0;
        }
    }
    
  10. src/book.vala를 열고 파일에서 아래의 코드 조각을 찾아라.
    private string title;
    private string isbn;
    private ArrayList<string> authors;
    
  11. 위의 코드를 아래로 대체하라.
    internal string title;
    internal string isbn;
    internal ArrayList<string> authors;
    
  12. 프로젝트의 최상위 디렉터리에서 terminal를 통해 아래의 명령을 발행하라.
    ./autogen.sh
    
  13. 프로젝트를 빌드하고 아래의 명령을 입력하여 모두 문제없이 작동하는지 확인하라.
    make all
    
  14. 아래 명령을 실행하여 단위 테스트를 만들어 실행하라.
    make check
    
  15. 아래와 비슷한 내용이 출력되는지 확인하라.
    /test-isbn: OK
    /test-title: OK
    /test-add-author: OK
    PASS: test_book
    /test-add-stock: OK
    /test-remove-stock: OK
    /test-stock-alert: OK
    PASS: test_bookstore
    ==================
    All 2 tests passed
    ==================
    


무슨 일이 일어났는가?

와우! 순식간에 완성했다.


테스트 경로와 테스트 결과를 표시함으로써 코드의 유효성을 검사하는 단위 테스트가 생겼다. 앞 절을 통해 테스트 경로 모두에게서 OK를 수신하였음을 확인하였을 것이다. 테스트에 대한 통계도 포함되어 있었다.


먼저 configure.ac와 Makefile.am을 수정함으로써 autotools 기본구조를 준비한 후에 tests 디렉터리에 새로운 Makefile.am 파일을 생성하였다. configure.ac의 output 섹션과 Makefile.am의 SUBDIRS 섹션 모두에 tests 디렉터리를 넣었다. 이들이 없다면 tests 디렉터리는 autotools와 나머지 빌드 기본구조에 알려지지 않을 것이다.


그러면 테스트 파일이 생긴다. Makefile.am에서 아래와 같은 행을 찾을 수 있을 것이다.

TESTS=test_book test_bookstore

check_PROGRAMS = test_book test_bookstore


이는 두 개의 테스트 프로그램, 즉 test_book과 test_bookstore가 있음을 autotools로 알려준다. 첫 행은 우리가 make check 명령을 발행하면 autotools가 명시된 프로그램을 실행하도록 만든다. 두 번째 행은 autotools에게 명시된 이름으로 된 바이너리를 생성하라고 말한다.


그러면 test_book과 test_bookstore에 해당하는 섹션이 생긴다. test_book에 관한 섹션에서는 다음과 같은 흥미로운 점이 발견된다.

test_book_SOURCES = \
    test_book.vala ../src/book.vala


이는 test_book 프로그램에 대한 소스 코드 파일이 test_book.vala와 book.vala임을 autotools로 알리는데, 이 파일들은 ../src 디렉터리에 위치한다. test_bookstore 프로그램에 대해서도 마찬가지다. 테스트 중인 소스 코드와 단위 테스트를 함께 컴파일하는 것이 보통이다.


이제 단위 테스트를 어떻게 쓰는지 확인해보자. 먼저 test_book.vala를 살펴보자.

public class TestBook {


각 단위 테스트마다 특정 객체를 테스트하는 클래스가 있다. 클래스는 객체명 앞에 Test라는 단어를 붙여 명명한다. 여기서 객체는 Book이므로 단위 테스트명은 TestBook이 되겠다. 이는 일반 클래스에 해당한다.


다음으로 테스트하고자 하는 객체에 실행하길 원하는 테스트를 static 함수에서 정의한다. 함수들이 static한 이유는 단위 테스트에서 test 클래스의 객체를 인스턴스화하지 않을 것이기 때문이다.


하나의 테스트만으로 관련된 사례를 모두 테스트해야 한다. 첫 번째 테스트에서는 ISBN이 올바로 설정되었는지 테스트하길 원한다. 여기서 ISBN 값이 1인 Book 객체를 생성하고, assert 함수를 이용해 isbn member의 값이 실제로 1인지 검사한다.

static void test_isbn ()
{
    var b = new Book("1", "title");
    assert(b.isbn == "1");
}


조금 어리석다고 생각할지도 모르겠다. 그렇게 빤한 것을 왜 굳이 검사하려 할까? 인간의 눈으로 볼 때는 항상 검사를 통과할 것이라고 생각할지도 모른다. 하지만 코드가 커질수록, 그리고 의도적으로 또는 뜻하지 않게 코드를 약간만 변경하는 경우 테스트가 실패할 수 있다는 사실을 명심하길 바란다. 필자의 주장을 강조하기 위해 book.vala의 실제 코드를 살펴보도록 하겠다.

public Book(string isbn, string title) {
    this.isbn = isbn;
    this.title = title;
    authors = new ArrayList<string>();
}


테스트에서 우리 코드는 객체를 인스턴스화한 직후 isbn member를 확인하였다. 아래와 같이 생성자를 수정했다고 가정해보자.

public Book(string isbn, string title) {
    isbn = isbn;
    title = title;
    authors = new ArrayList<string>();
}


앞의 행에서 어쩌다가 this를 제거하는 일이 발생할지도 모른다. 코드는 여전히 실행되겠지만 결과를 틀리다. 테스트가 없이는 이를 간과하기 쉬우며, 결국 불평하는 고객에게 프로그램이 계산을 잘못 수행한다는 소식을 전해주어야 할지도 모른다! 이제 실제로 앞의 오류를 만들어 make check를 다시 실행해보자. 아래의 화면 출력에서 알 수 있듯이 즉시 통지를 받을 것이다.

/test-isbn: **
ERROR:test_book.c:100:test_book_test_isbn: assertion failed: (g_strcmp0
(_tmp1_, "1") == 0)
/bin/bash: line 5: 9305 Aborted ${dir}$tst
FAIL: test_book
/test-add-stock: OK
/test-remove-stock: OK
/test-stock-alert: OK
PASS: test_bookstore
===================
1 of 2 tests failed
===================


예상한대로 test_isbn 함수가 오류를 정확히 가리키고 있음을 확인할 수 있다. 이제 test_isbn이 없다고 가정해보자.


함수로 전달한 표현식의 올바른 값을 확인하기 위해 assert를 사용한다. assert 함수는 값이 true가 아닌 경우 즉시 프로그램을 종료할 것이기 때문에, 프로그램이 종료되면 우리는 무언가 잘못되었음을 알게 된다.


Book 클래스를 아래와 같은 모양이 되도록 수정할 필요가 있었다.

internal string title;
internal string isbn;
internal ArrayList<string> authors;


그리고 모든 private member를 internal로 변경했다. Vala는 같은 패키지 내 모든 클래스의 내부 member를 연다. 즉, 단위 테스트가 member로 직접 접근할 수 있다는 뜻이다. 그렇지 않으면 단위 테스트는 authors member 등으로 접근할 수 없을 것이다. C++에서는 friend 클래스라는 것이 있어서 이를 통해 member로 접근하는 동시 member를 private하게 유지할 수도 있는데, Vala는 이에 해당하는 기능이 없다는 점에서 짜증스럽기도 하다.


그 다음 행에서는 제목 값을 검사하는 테스트가 있다.

static void test_title ()
{
    var b = new Book("1", "title");
    assert(b.title == "title");
}


이는 test_isbn 테스트와 같은 방식으로 작동한다. 다음으로, addAuthor 함수가 그 기능에 맞게 작동하는지 검사하기 위한 테스트가 있다.

static void test_add_author()
{
    var b = new Book("1", "title");
    b.addAuthor("author1");
    b.addAuthor("author2");
    b.addAuthor("author3");
    assert(b.authors.size == 3);
}


여기서 authors 배열 리스트를 확인한다. 이는 addAuthor 함수를 이용해 세 명의 저자를 Book 객체로 추가한 후 3의 값을 가져야 한다. Book 객체에는 더 이상 테스트가 필요한 함수가 없다. 나머지 함수는 데이터를 화면으로 출력할 뿐이다. 그러므로 단위 테스트의 main 함수로 넘어가 보자.

static int main (string[] args)
{
    Test.init (ref args);


먼저 Test.init 함수를 호출함을 확인할 수 있다. 이것은 GLib.Test 클래스의 초기화 함수다.


다음으로 앞서 정의한 함수를 모두 등록하고, 함수에 테스트 경로를 할당한다. 테스트 경로는 실제 테스트 함수를 보내고자 하는 경로의 임의의 이름이다. GLib.Test 클래스는 각 함수가 테스트 경로에 의해 표현되도록 요한다. 그 다음, 루트 경로부터 시작해 모든 경로를 실행하도록 Test.run 함수를 호출한다. 루트 경로는 계층구조에서 최상위 경로인 루트 디렉터리와 같다.

Test.add_func ("/test-isbn", test_isbn);
Test.add_func ("/test-title", test_title);
Test.add_func ("/test-add-author", test_add_author);

Test.run ();

return 0;


시도해보기 - 실제 값 확인하기

일부 사례에서는 데이터 구조체로 넣는 실제 값, 즉 Book 클래스의 authors 배열 리스트와 같은 값을 확인할 필요가 있다. test_add_author에서는 배열의 길이만 확인하고 실제 값은 확인하지 않았다. 이러한 확인은 스스로 데이터 구조체를 만들 때 또는 데이터 구조체가 복잡할 때도 필요한데, 가령 데이터의 삽입 후 데이터 순서가 중요한 경우를 예로 들 수 있겠다. 하나 이상의 데이터 집합을 사용하여 확인하는 수도 있다.


이제 우리만의 데이터 구조체가 있다고 가정해보자. 따라서 우리가 입력하는 데이터가 실제로 그렇게 입력되었는지 확인하는 것이 임무다.


테스트 스터빙

코드에서는 비결정적인 행위, 즉 단위 테스팅에 나쁜 행위를 제공할 수 있는 외부 라이브러리를 사용할 때가 종종 있다. 이를 막기 위해 "stubbing"(스터빙) 기법을 사용한다. 이 기법은 함수를 모방하는 새로운 라이브러리와 원본 라이브러리의 API를 생성하는 과정을 수반한다. 우리 단위 테스트에서는 원본 라이브러리 대신 이렇게 새로운 가짜 라이브러리를 이용해 코드에서 사용하는 API의 출력을 제어할 수 있도록 한다.


실행하기 - 스텁 생성하기

이제 제 4장, GNOME 코어 라이브러리 사용하기에서 살펴본 core_settings 프로젝트로 돌아가 보자. 이는 특정 값을 이용해 GSettings를 얻고 설정하는 실험이다.

  1. Anjuta로 프로젝트를 열어라.
  2. 앞의 활동과 같이 기본구조를 구성하라. 즉, tests 디렉터리를 생성하고, 이를 프로젝트의 최상위 디렉터리에 위치한 configure.ac 와 Makefile.am 으로 넣어라.
  3. CoreSettings 클래스를 약간 변경해야 한다. 원본 코드는 CoreSettings 클래스와 동일한 파일에 메인 함수를 갖고 있다. 이 둘을 구분해야 한다.
  4. 따라서 우리가 할 일은 src/Makefile.am을 조정하는 것이다. 파일에서 구체적으로 SOURCES 섹션을 수정하라. 파일 내 코드는 다음과 같다.
    core_settings_SOURCES = \
        core_settings.vala config.vapi
    

    위를 아래와 같이 변경해야 한다.
    core_settings_SOURCES = \
        core_settings.vala main.vala config.vapi
    
  5. 그리고 src/core_settings.vala를 아래와 같이 수정하라.
    using GLib;
    public class CoreSettings : Object
    {
        Settings settings = null;
        public CoreSettings ()
        {
            settings = new Settings("org.gnome.desktop.background");
        }
        public string get_bg()
        {
            if (settings == null) {
                return null;
            }
            return settings.get_string("picture-uri");
        }
        public void set_bg(string new_file)
        {
            if (settings == null) {
                return;
            }
            if (settings.set_string ("picture-uri", new_file)) {
                Settings.sync ();
            }
        }
    }
    
  6. 이후 원본 메인 함수를 포함하는 새로운 파일 src/main.vala를 생성하라. 단, 이번에는 Main 이라 불리는 전용 클래스로 넣어라.
    public class Main {
        static int main (string[] args)
        {
            var app = new CoreSettings ();
            stdout.printf("%s\n", app.get_bg());
            app.set_bg ("http://www.gnome.org/wp-content/themes/gnome-grass/images/gnome-logo.png");
            return 0;
        }
    }
    
  7. 나누는 작업이 끝나면 tests 디렉터리로 넘어간다. 먼저 디렉터리에 Makefile.am을 생성하라. 파일에는 아래의 코드를 사용한다.
    AM_CPPFLAGS = \
        -DPACKAGE_LOCALE_DIR=\""$(localedir)"\" \
        -DPACKAGE_SRC_DIR=\""$(srcdir)"\" \
        -DPACKAGE_DATA_DIR=\""$(pkgdatadir)"\" \
        $(CORE_SETTINGS_CFLAGS)
    
    AM_CFLAGS =\
        -Wall\
        -g
    
    TESTS=test_settings
    
    check_PROGRAMS = test_settings
    
    test_settings_SOURCES = \
        ../src/core_settings.vala test_settings.vala stub/gsettings.vala
    
    test_settings_VALAFLAGS = \
        --pkg gee-1.0
    
    test_settings_LDFLAGS = \
        -Wl,--export-dynamic
    
    test_settings_LDADD = $(CORE_SETTINGS_LIBS)
    
  8. 다음으로 tests 디렉터리에 새로운 파일 test_settings.vala 를 생성하라.
    public class TestSettings {
        static void test_set_get()
        {
            var s = new CoreSettings();
            s.set_bg("test123");
            assert (s.get_bg() == "test123");
        }
    
        static int main (string[] args)
        {
            Test.init (ref args);
    
            Test.add_func ("/test-set-get", test_set_get);
    
            Test.run ();
    
            return 0;
        }
    }
    
  9. 그 다음 tests 디렉터리에 stub 이라는 디렉터리를 생성하라.
  10. stub 디렉터리에 새로운 파일 gsettings.vala 를 생성하라. 이 파일은 아래의 코드로 채워라.
    using Gee;
    
    public class Settings : Object {
        HashMap<string,string> map;
    
        [CCode(cname="g_settings_new")]
        public Settings(string s) {
            map = new HashMap<string,string>();
        }
    
        [CCode(cname="g_settings_sync")]
        public static void sync() {
            /* do nothing */
        }
    
        [CCode(cname="g_settings_set_string")]
        public bool set_string(string key, string value) {
            map.set(key, value);
            return true;
        }
    
        [CCode(cname="g_settings_get_string")]
        public string get_string(string key) {
            return map.get(key);
        }
    }
    
  11. Terminal에서 아래의 명령을 호출하여 프로젝트를 재빌드하라.
    ./autogen.sh
    
  12. 마지막으로 아래의 행을 실행하라.
    make check
    
  13. 테스트가 아래와 같은 메시지를 표시하면서 완전하게 통과되도록 확실히 하라.
    /test-set-get: OK
    PASS: test_settings
    =============
    1 test passed
    


무슨 일이 일어났는가?

기본구조를 빌드한 후에는 코드를 두 개의 부분, 즉 CoreSettings 클래스와 Main 클래스로 나누었다. 프로그램에 하나 이상의 main 함수를 가질 수 없기 때문에 이 과정은 꼭 필요하며, 하나는 코드로부터, 나머지 하나는 테스트로부터 비롯된다. 이 과정이 필요한 더 중요한 이유는, 테스트하고자 하는 클래스가 다른 곳에는 속하지 않고 클래스에만 속하는 코드를 포함하기 때문이다. 여기까지 완료하면 깨끗하고 정돈된 코드를 갖게 되며, 무언가 잘못되면 추적하기 수월할 것이다.


이는 원본 메인 함수를 고유의 클래스가 있는 고유의 파일로 넣음으로써 해결할 수 있다. 원본 Main 클래스의 이름도 그 기능을 반영하도록 CoreSettings로 다시 명명한다 (GNOME의 코어 라이브러리에서 Settings를 이용).


그리고 테스트 파일을 tests 디렉터리에 넣는다. tests 디렉터리에 stub이라는 새로운 하위디렉터리도 생성하였다. 그 안에는 gsettings.vala 파일이 있다. 해당 파일은 GSettings 클래스의 이미테이션을 포함한다. 스텁(stub)은 우리가 코드에서 사실상 사용하는 함수만 포함한다. 그 외 사용하지 않는 함수는 구현해선 안 된다.


이번 예제에서는 Gee로부터 해시 맵 구현을 이용해 GSettings를 구현한다.

using Gee;
public class Settings : Object {
    HashMap<string,string> map;


코드에 생성자가 사용되었으므로 이를 구현할 필요가 있다. 내부에서 해시 맵을 초기화한다. 인자는 중요하지 않으므로 어디에도 보관할 필요가 없다. Vala에서 스터빙은 매우 까다롭다. 이는 Vala 코드가 사용하는 함수들이 사실 생성된 C 함수들이기 때문이다. 따라서 원본 라이브러리를 이용한 스텁에 정확히 동일한 클래스명과 함수명이 있다 하더라도 생성된 클래스 및 함수명은 더 이상 생성된 C 파일에서 동일하지 않다. 스텁에서 클래스명과 함수명의 차이는 테스트 프로그램에서 사용되지 않을 것이다.


이러한 문제를 해결하기 위해서는 스터빙된 라이브러리와 동일한 이름을 생성할 것을 Vala에게 알릴 필요가 있다. 따라서 Vala 코드에서 클래스 선언 또는 함수 선언 직전에 CCode 속성을 이용한다. 본문에 제시한 예제에서는 이 속성 바로 뒤에 생성자가 따라온다.

[CCode(cname="g_settings_new")]
public Settings(string s) {
    map = new HashMap<string,string>();
}


이는 Vala가 Settings 생성자로부터 g_settings_new C 함수를 생성하도록 확보할 것이다.


코드에서 Settings.sync 함수를 사용하므로 sync 함수를 정의할 필요가 있다. GSettings API 참조에 따라 함수는 정적(static)이므로 그와 동일하게 생성해야 한다.

[CCode(cname="g_settings_sync")]
public static void sync() {
    /* do nothing */
}


기능이 필요하지 않더라도 함수를 생성해야 한다. 이 과정을 건너뛰면 Vala는 gsettings.vala에서 sync 함수를 찾을 수 없을 것이고 원본 라이브러리로부터 이름을 결정(resolve)하려는 시도를 하지 않을 것이기 때문에 결국 컴파일이 실패할 것이다.


그 다음으로 set_string 함수를 생성한다. 이 함수에서는 단순히 해시 맵을 래핑하고 key와 value 쌍을 맵으로 삽입한다.

[CCode(cname="g_settings_set_string")]
public bool set_string(string key, string value) {
    map.set(key, value);
    return true;
}


get_string도 마찬가지다. CCode 속성을 사용하고, 요구되는 C 함수명을 정의해야 함을 주목한다.

    [CCode(cname="g_settings_get_string")]
    public string get_string(string key) {
        return map.get(key);
    }
}


이제 테스트 코드를 한 번 살펴보자.

public class TestSettings {
    static void test_set_get()
    {
        var s = new CoreSettings();
        s.set_bg("test123");
        assert (s.get_bg() == "test123");
    }


CoreSettings 클래스 코드를 바탕으로 set_bg와 get_bg 함수만 테스트하면 된다. 클래스에서 기능을 제공하는 함수는 더 이상 존재하지 않는다. 따라서 곧바로 main 함수를 구현한다.

    static int main (string[] args)
    {
        Test.init (ref args);
        Test.add_func ("/test-set-get", test_set_get);
        Test.run ();
        return 0;
    }
}


여기서는 Test 프레임워크를 초기화하고, 테스트 경로를 추가한 다음 테스트를 실행한다. 아 참, 스텁은 어디서 사용되었지? tests/Makefile.am 을 다시 살펴보고 미스터리를 풀어보자. SOURCES 섹션을 훑어보자.

test_settings_SOURCES = \
    ../src/core_settings.vala test_settings.vala stub/gsettings.vala


이 부분은 Vala가 세 개의 파일을 동시에 컴파일해야 한다고 말한다. 테스트 코드는 GSettings로 어떤 참조도 갖고 있지 않으며, core_settings.vala에 있는 소스만 GSettings로의 참조를 갖고 있다. 따라서 core_settings.vala를 살펴보면 아래의 내용이 보일 것이다.

public CoreSettings ()
{
    settings = new Settings("org.gnome.desktop.background");
}


이는 사실상 GSettings 대신 gsettings.vala로부터 settings 객체를 인스턴스화하는데, 여기서는 gsettings.vala로부터 get_string을 호출한다.

public string get_bg()
{
    if (settings == null) {
        return null;
    }
    return settings.get_string("picture-uri");
}


스터빙에서 핵심은 우리가 사용하는 라이브러리와 정확히 똑같은 API를 생성하는 것이다. 빌드를 할 때 코드, 테스트, 스텁을 동시에 컴파일한다. 그렇지 않으면 Vala는 올바르지 않은 API를 사용하고 있다거나 심지어 빌드가 성공적으로 이루어지지 않았다고 불평하여 결국 스텁이 제대로 호출되지 않을 것이다.


GUI 모듈 테스트하기

지금까지는 함수를 간편하게 테스트하여 인자로 전달하는 값을 기반으로 결과를 제공하였다. 하지만 GUI는 사용자 행위로부터 입력, 즉 마우스의 클릭이나 키보드의 타이핑과 같은 입력을 기대한다. 좀 더 일반적으로 말해, GUI는 하나 또는 그 이상의 이벤트를 바탕으로 특정 출력을 제공함으로써 응답한다.


GUI 모듈을 테스트할 때는 앞서 학습한 방법을 더 이상 사용할 수가 없다. GUI 모듈을 테스트할 때는 아래의 측면들이 처리되어야 한다.

  • GUI 테스트 애플리케이션을 준비하기 위한 환경 설정
  • 그래픽 프레임워크 초기화
  • UI 이벤트의 발생과 처리
  • 이벤트 루프 관리


이제 이 문제들을 논해보자.


실행하기 - GTK+ 모듈 테스트하기

기존의 custom_composite Vala 프로젝트에 단위 테스트를 넣어보자.

  1. 언제나처럼 tests 디렉터리를 생성하여 최상위 디렉터리에 위치한 configure.ac와 Makefile.am으로 넣어보자.
  2. 코드를 두 부분으로 나누어라. custom_window.vala에 다음과 같은 코드를 사용하라.
    using GLib;
    using Gtk;
    
    public class CustomWindow : Window
    {
        Entry entry;
        Box box;
        public signal void search_updated(string value);
    
        void show_search_box() {
            entry.show();
            entry.has_focus = true;
        }
    
        void hide_search_box() {
            entry.hide();
        }
        public override void add(Widget widget) {
            if (widget != box) {
                box.pack_start(widget, true, true);
            } else {
                base.add(widget);
            }
        }
    
        public CustomWindow ()
        {
            box = new Box(Orientation.VERTICAL, 0);
            entry = new Entry();
            box.pack_start (entry, false, true);
            box.show();
    
            add(box);
    
            key_release_event.connect((event) => {
                search_updated(entry.text);
                return false;
            });
    
            key_press_event.connect((event) => {
                if (!entry.get_visible()) {
                    show_search_box();
                }
                return false;
            });
        }
    }
    
  3. main 클래스에는 아래의 코드를 사용하라.
    using GLib;
    using Gtk;
    
    public class Main {
        static int main (string[] args)
        {
            Gtk.init (ref args);
            var window = new CustomWindow();
            var label = new Label("This is a text");
    
            window.add(label);
            window.resize(400,400);
            window.search_updated.connect((value) => {
                label.set_text("Searching for keyword " + value);
            });
    
            label.show();
            window.show();
            Gtk.main ();
    
            return 0;
        }
    }
    
  4. 그리고 구현 클래스와 메인 클래스 모두를 포함하도록 Makefile.am 파일을 수정하라.
    custom_composite_SOURCES = \
        custom_composite.vala main.vala config.vapi
    
  5. 다음으로 테스트용 Makefile.am 을 생성하라.
    AM_CPPFLAGS = \
        -DPACKAGE_LOCALE_DIR=\""$(localedir)"\" \
        -DPACKAGE_SRC_DIR=\""$(srcdir)"\" \
        -DPACKAGE_DATA_DIR=\""$(pkgdatadir)"\" \
        $(CUSTOM_COMPOSITE_CFLAGS)
    
    AM_CFLAGS =\
        -Wall\
        -g
    
    TESTS=test_custom_window
    check_PROGRAMS = test_custom_window
    
    test_custom_window_SOURCES = \
        ../src/custom_composite.vala test_custom_window.vala
    
    test_custom_window_VALAFLAGS = \
        --pkg gtk+-3.0
    
    test_custom_window_LDFLAGS = \
        -Wl,--export-dynamic
    
    test_custom_window_LDADD = $(CUSTOM_COMPOSITE_LIBS)
    
  6. tests 안에 새로운 파일 test_custom_window.vala를 생성하고 아래의 코드를 사용해보라.
    using Gtk;
    
    public class TestCustomWindow {
    
        static void process_events()
        {
            while (Gtk.events_pending ()) {
                Gtk.main_iteration_do(true);
            }
        }
    
        static void test_initial_child ()
        {
            var window = new CustomWindow();
            var child = window.get_child () as Box;
            window.show_now();
    
            assert (child != null);
            window.destroy ();
        }
    
        static void test_child_visibility ()
        {
            var window = new CustomWindow();
            var child = window.get_child () as Box;
            window.show_now();
    
            var entry_is_found = false;
            var children = child.get_children ();
            if (children != null && children.nth(0) != null) {
    
                var entry = children.nth_data(0) as Entry;
    
                assert (entry != null);
                assert (entry.visible == false);
    
                Gdk.test_simulate_key (window.get_window (), 1, 1, Gdk.Key.a, 0, Gdk.EventType.KEY_PRESS);
                Gdk.test_simulate_key (window.get_window (), 1, 1, Gdk.Key.a, 0, Gdk.EventType.KEY_RELEASE);
    
                process_events (); // Process events
    
                assert (entry.visible == true);
    
                entry_is_found = true;
            }
    
            assert (entry_is_found);
            window.destroy ();
        }
    
        static void test_search_updated ()
        {
            var window = new CustomWindow();
            window.show_now();
            var search_updated_was_emitted = false;
            var search_updated_was_correct = false;
    
            window.search_updated.connect ((text) => {
                search_updated_was_emitted = true;
                if (text == "a") {
                    search_updated_was_correct = true;
                }
            });
            Gdk.test_simulate_key (window.get_window (), 1, 1, Gdk.Key.a, 0, Gdk.EventType.KEY_PRESS);
            Gdk.test_simulate_key (window.get_window (), 1, 1, Gdk.Key.a, 0, Gdk.EventType.KEY_RELEASE);
    
            process_events (); // process events
    
            assert (search_updated_was_emitted);
            assert (search_updated_was_correct);
            window.destroy ();
        }
    
        static int main (string[] args)
        {
            Gtk.test_init (ref args);
    
            Test.add_func ("/test-search-updated", test_search_updated);
            Test.add_func ("/test-initial-child", test_initial_child);
            Test.add_func ("/test-child-visibility", test_child_visibility);
    
            Idle.add (() => {
                Test.run ();
                Gtk.main_quit ();
                return true;
            });
    
            Gtk.main ();
    
            return 0;
        }
    }
    
  7. 아래 명령을 이용해 프로젝트를 다시 빌드하라.
    ./autogen.sh
    
  8. 아래 명령을 발행하여 GUI 초기화를 위한 환경을 만들어라.
    export DISPLAY=:0
    

    위 명령은 처음으로 GUI 테스트를 실행할 때 한 번만 실행되어야 한다. 이는 terminal 콘솔에서 테스트를 실행할 때 필요하며, 테스트를 GNOME 내부에서 직접 실행할 경우에는 필요 없다.
  9. 테스트를 실행하면 테스트가 완료되기 전에 잠시 동안 창이 깜빡임을 확인할 수 있을 것이다. 테스트를 실행하기 위해선 셸에 아래 명령을 입력한다.
    make check
    
  10. 모든 테스트를 성공적으로 통과하는지 확인하라.
    /test-search-updated: OK
    /test-initial-child: OK
    /test-child-visibility: OK
    PASS: test_custom_window
    =============
    1 test passed
    =============
    


무슨 일이 일어났는가?

이제 단위 테스트가 어떻게 이루어지는지에만 집중하자.


CustomWindow 클래스가 무엇을 하는지 살펴보자.

  • 이것은 창에 해당하며, 내부에 텍스트 엔트리를 생성한다.
  • 창은 하나의 자식만 취할 수 있으며, 자식을 취하더라도 텍스트 엔트리를 제거해선 안 된다.
  • 사용자가 창에 있는 키를 누르면 search_updated 시그널이 발생한다.


이는 단위 테스트를 생성하는 전략에서 기본이다.


첫 번째 테스트를 위해 생성자가 Entry 와 다른 위젯의 플레이스홀더로 Box 객체를 올바로 생성하는지 확인한다.

static void test_initial_child ()
{
    var window = new CustomWindow();
    var child = window.get_child () as Box;
    window.show_now();
    assert (child != null);
    window.destroy ();
}


여기서는 단순히 생성자가 Box 객체를 갖고 있는지 유무를 확인한다. 꽤 간단한 테스트다.


다음 테스트에서는 텍스트 입력의 가시도(visibility)를 확인한다. 처음에 텍스트 엔트리는 숨겨져 있지만 어떤 키든 누르게 되면 엔트리가 보여야 한다.

static void test_child_visibility ()
{
    var window = new CustomWindow();
    var child = window.get_child () as Box;
    window.show_now();


여기서 창을 즉시 표시하기 위해선 show_now를 호출한다. show를 사용하면 다른 이벤트들이 처리될 때까지 표시가 지연될 수 있다.

var entry_is_found = false;
var children = child.get_children ();
if (children != null && children.nth(0) != null) {

    var entry = children.nth_data(0) as Entry;


다음으로 텍스트 엔트리를 얻는다. 엔트리는 창의 첫 번째 자식이므로 nth_data 메서드를 사용하여 색인 0에 위치한 요소를 나타내기 위해 0을 전달한다.

assert (entry != null);
assert (entry.visible == false);


리턴된 위젯을 Entry 위젯으로 형변환(cast)하기 위해 Entry 를 사용한다. 이러한 형변환을 이용하면 리턴된 위젯이 Entry 위젯이 아닐 경우 null을 리턴할 것이다. 여기서는 entry 변수를 null 값과 비교함으로써 변수가 실제로 Enry 위젯 타입인지 확인한다. 성공하면 가시도가 false로 설정되었는지 계속 확인하는데, 엔트리의 초기 가시도가 false여야 하기 때문이다.

Gdk.test_simulate_key (window.get_window (), 1, 1, Gdk.Key.a, 0, Gdk.EventType.KEY_PRESS);
Gdk.test_simulate_key (window.get_window (), 1, 1, Gdk.Key.a, 0, Gdk.EventType.KEY_RELEASE);


다음으로 Gdk 클래스로부터 test_simulate_key를 사용함으로써 키 누름(key press) 다음에 키 누름해제(key release)의 시뮬레이션을 실행한다. 해당 이벤트들을 window 객체로 전송한다. 함수는 Gdk.Window를 필요로 하므로 get_window를 이용해 window 객체로부터 Gdk.Window를 전달한다. window 객체를 표시하기 위해선 꼭 show_now를 사용해야 하는데, 단순히 show를 사용 시 표시하기가 실행되지 않으면 get_window 함수는 null을 리턴하고 테스트는 실패할 것이기 때문이다.


누름과 누름해제 이벤트의 어떤 위치든 이용할 수 있다. 여기서는 (1,1) 좌표를 사용한다. 우리가 전달하는 키는 a 키이므로 어떤 수식키(key modifier)도 없이 Gdk.Key.a 를 사용한다. 누름 이벤트를 먼저 보낸 다음 누름 해제 이벤트를 전송한다.

        process_events (); // Process events
        assert (entry.visible == true);

        entry_is_found = true;
    }
    assert (entry_is_found);
    window.destroy ();
}


키를 전송한 직후, process_events 함수를 호출함으로써 대기 중인 이벤트를 처리할 필요가 있다. 여기까지 완료되면 이제 가시도를 확인할 수 있다. process_events를 호출하지 않으면 키 이벤트가 시스템에 의해 처리되지 않아 entry 객체의 가시도가 변경되지 않을 수도 있다. 그 다음, 창을 종료(destroy)한다.


process_events 함수가 하는 일을 살펴보자.

static void process_events()
{
    while (Gtk.events_pending ()) {
        Gtk.main_iteration_do(true);
    }
}


위는 기본적으로 큐에 대기 중인 이벤트는 모두 처리한다. 이벤트를 발견하면 단순히 main_iteration_do를 호출함으로써 메인 루프를 실행하고, 설사 GTK+가 막고 있더라도 우리는 true 인자를 제공함으로써 GTK+가 연산을 완료하도록 허용한다.


다음으로 시그널이 올바른 값으로 적절하게 발생하였는지 확인한다.

static void test_search_updated ()
{
    var window = new CustomWindow();
    window.show_now();
    var search_updated_was_emitted = false;
    var search_updated_was_correct = false;

    window.search_updated.connect ((text) => {
        search_updated_was_emitted = true;
        if (text == "a") {
            search_updated_was_correct = true;
        }
    });


여기서는 search_updated 시그널을 연결한다. 로컬 변수를 이용해 핸들러의 성공을 기록한다. 그리고 시그널로부터 텍스터가 a 인지 확인하는데, 이는 창으로 전송하는 키 이벤트에 해당한다.

Gdk.test_simulate_key (window.get_window (), 1, 1, Gdk.Key.a, 0, Gdk.EventType.KEY_PRESS);
Gdk.test_simulate_key (window.get_window (), 1, 1, Gdk.Key.a, 0, Gdk.EventType.KEY_RELEASE);

process_events (); // process events


키 이벤트를 전송하고 나면 process_events를 호출한다. 그 이유는 시그널 핸들러가 호출되어 결과를 주장할 수 있도록 확보하기 위함이다.

assert (search_updated_was_emitted);
assert (search_updated_was_correct);
window.destroy ();


main 함수에서는 먼저 Gtk.test_init 함수를 호출한다. 이는 테스트에 적합한 환경을 구성하기 위함이다. 다음으로 테스트 경로를 추가한다.

static int main (string[] args)
{
    Gtk.test_init (ref args);

    Test.add_func ("/test-search-updated", test_search_updated);
    Test.add_func ("/test-initial-child", test_initial_child);
    Test.add_func ("/test-child-visibility", test_child_visibility);


GTK+ 시스템을 준비하여 실행시키기 위해선 여전히 Gtk.main이 필요하다. 테스트를 GNOME 밖에서 실행할 때는 테스트가 실행될 수 있도록 X11 서버를 실행하고 환경을 구성할 필요가 있다. 이는 DISPLAY 환경 변수를 우리 X11 표시 번호로 설정함으로써 실행된다. 보통 값은 :0이다. 원격 테스트를 실행할 경우 약간 번거로운 셋업에 해당한다.


하지만 우선 실행되고 나면 테스트가 실행되는지 확보할 필요가 있다. 따라서 idle 핸들러를 구성할 필요가 있다. 핸들러 내부에서는 간단히 테스트를 실행하고 난 후 GTK+ 시스템을 종료한다.

Idle.add (() => {
    Test.run ();
    Gtk.main_quit ();
    return true;
});

Gtk.main ();


시도해보기 - 빠진 테스트 추가하기

앞의 테스트에서는 window 객체 내부에서 Box 객체의 존재만 확인하였다. 따라서 확인해야 할 또 다른 필수 테스트를 빠뜨린 셈이다.

  • Entry가 실제로 생성되었는지 여부
  • 새로운 위젯을 window 객체로 추가할 때 Entry가 동시에 존재할 수 있는지 여부


답을 알아내고 테스트를 생성해보자!


요약

GNOME 코드에서 단위 테스팅을 어떻게 실행하는지 살펴보았다. 초보자에게 가장 까다로운 부분은 어떤 테스트를 써야 하는지 알아내는 것이다. 이 주제를 논하기 위해서는 먼저 테스트하길 원하는 코드가 제공하는 기능을 먼저 확인해야 한다. 그 다음 다른 인자를 제공함으로써 코드에 존재하는 각 branch로 좁혀나가면 모든 사례에서 코드 경로를 살펴볼 수 있을 것이다.


결정적인 테스트를 생성해야 하지만 우리가 사용하는 라이브러리는 (심지어 본인의 코드조차) 비결정적인 행위를 제공할 수 있음을 이해했을 것이다. 이러한 문제를 해결하기 위해서는 각 라이브러리에 대해 잠재적으로 비결정적 값을 제공할 수 있는 스텁을 생성할 필요가 있다. 사용 중인 라이브러리가 너무 복잡하거나 단위 테스트를 실행할 때 복잡한 셋업을 필요로 하는 경우에도 스터빙을 실행할 수 있다.


마지막으로, GTK+ 고유의 초기화 함수를 이용함으로써 GUI 모듈을 테스트하는 방법을 학습하였다. 무언가를 주장하기 전에는 대기 중인 이벤트를 모두 처리할 필요가 있다. 또 즉시 Gdk 창을 구성하기 위해서는 show_now 함수를 사용해야 함을 배웠다.


우리가 쓴 테스트는 코드를 변경할 때마다 실행되어야 한다. 그 이유는 코드에서 회귀(regression)를 원치 않기 때문이다. 많은 소프트웨어 개발자들이 소스 코드 저장소로 코드를 전송하기 전에 테스트를 실행하고, 존재하는 모든 테스트 사례를 실행함으로써 밤마다 테스트를 구성한다.


마지막 장에서는 두 가지 큰 프로젝트, 브라우저와 트위터(twitter) 클라이언트를 실행하는 방법을 학습할 것이다. 지금까지 학습한 기법을 모두 사용할 예정이니 마음을 다잡도록!


Notes