ข้ามไปที่เนื้อหาหลัก

ใช้ Watchdog ช่วยทำ TDD สำหรับ C++ ด้วย Gtest และ CMake

เห็นคนรู้จักหลายๆ คนทำ TDD กัน เลยอยากทำบาง โดยมากมักใช้ Watchr เพื่อช่วนในการรัน Test Case ทันทีที่บันทึกไฟล์ เห็นแล้วสะดวกดี แต่พอดีเขียน Ruby ไม่เป็นเลยมากหาไลบราลีภาษา Python แทน ก็ไปเจอ Watchdog อาศัยแรงของตั้วเลยได้ Python script อย่างง่ายมาเล่น TDD สำหรับ Python กัน

วันนี้อยากเอามันมาใช้กับ C++ บ้างเล่นเอาเหนื่อยเหมือนกันกว่าจะเอา Python script ของตั้วมาใช้กับ C++ ซึ่งจากที่ดูๆ มา Google Test กับ CMake น่าจะตอบโจทย์ TDD ได้ระดับนึ่ง แต่ต้องอาศัยการจัดรูปแบบของไดเร็กทอรีเข้าร่วมด้วย ตอนนี้สคริปต์ยังมีการกำหนดหลายๆ อย่างตายตัวอยู่มาก อาจจะยังไม่เรียบร้อยดีเท่าที่ควร

หน้าตาไดเร็กทอรีประมาณนี้

.
├── src
│   └── xxx
│       └── xxx.cpp
├── tests
│   ├── CMakeLists.txt
│   ├── external
│   │   └── gtest
│   │       └── CMakeLists.txt
│   └── units
│       └── xxx
│           └── test_xxx.cpp
├── cpp-testrunner
│
└── CMakeLists.txt

สำหรับไดเร็กทอรีโปรเจคจะประกอบไปด้วย src tests CmakeLists.txt และ cpp-testrunner
  • src ใช้เก็บซอร์ซโค้ดของโปรแกรม
  • tests ใช้เก็บ Test Case ต่างๆ
  • CMakeLists.txt เป็น CMake หลักของโปรเจค
  • cpp_testrunner คือสคริปต์ที่เอามาช่วยรัน Test Case
นอกจากนี้ยังมี tests/CMakeLists.txt และ tests/external/gtest/CMakeLists.txt สองไฟล์นี้ทำหน้าที่สำหรับคอมไฟล์ Test ให้กับโปรแกรม โดยที่ tests/CMakeLists.txt จะเป็นส่วนหลักในการคอมไพล์และจะเรียก tests/external/gtest/CMakeLists.txt เพื่อคอมไพล์ไลบรารี Gtest อีกทีนึง นั้นก็หมายความว่า เครื่องที่จะรันจะต้องติดตั้งเพกเกจ libgtest-dev ไว้แล้ว โดยตัวอย่างการเขียน CMake ทั้งหมดกับ Gtest ได้มาจาก google-test-examples

สำหรับส่วนของการเขียน Test จะต้องสร้าง Unit Test ไฟล์ไว้ใน tests/units และมีได้เร็กทอรีที่ล้อกับ src ดังตัวอย่าง src/xxx/xxx.cpp จะต้องสร้าง Unit Test ไฟล์ไว้ที่ tests/units/xxx/test_xxx.cpp ใช้ test_ นำหน้าที่ไฟล์ สำหรับ cpp-testrunner หน้าตาจะเหมือนด้านล่าง ใช้ Python3 นะครับ


#!/usr/bin/env python
import os
import sys
import time
import re
from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler
 


def when_file_changed(filename):
    def projectname():
        """
            Projectname from name of current directory ('.')
            if there are dash '-' in its name replace to underscore '_'
        """
        return os.path.abspath(".").rsplit("/", 1)[1]
 
    def get_test_cases(filename):     
        test_case = []
        try:
            with open(test_file(filename), 'r') as f:
                src = f.read()
                test_case = list(set(re.findall('TEST\s*\((.*),', src)))
        except:
            pass

        return test_case
    
    def test_file(filename):
        basename = os.path.basename(filename)
        if not basename.startswith("test_"):
            filename = filename.replace("src", "tests/units")
            filename = filename.replace(basename, "test_" + basename)
        return filename

    def make_test_runner():
        build_test_path = os.path.abspath(".")+'/build-test'
        test_path = os.path.abspath(".")+'/tests'
        os.system("cmake -B{build_test_path} -H{test_path}".format(build_test_path=build_test_path, test_path=test_path))
        os.system("make -C {build_test_path}".format(build_test_path=build_test_path))
            
 
    cls()
    print(os.path.abspath(filename))
    make_test_runner()

    # extract testcase

    test_cases = get_test_cases(test_file(filename))
    if len(test_cases) == 0:
        print('There are no unit tests, skip test running')
        return

    cmd = 'build-test/'+projectname()+'-test'\
            + ' --gtest_filter='+'.*:'.join(test_cases)+'.*'
    
    print(cmd)
    os.system(cmd)
 
 
def cls():
    os.system('cls' if os.name == 'nt' else 'clear')
 
 
class ModifiedHandler(PatternMatchingEventHandler):
    patterns = ['*.cpp', '*.hpp', '*cc', '*.h']  # Monitor only matched patterns
    def on_created(self, event):
        """" For Vim :w - not modify that deleted and created file instead. """       
        if '/build-test/' in event.src_path:
            return

        when_file_changed(event.src_path)
 
 
if __name__ == '__main__':
    args = sys.argv[1:]

    path = args[1] if args else '.'
    build_path = path + '/build-test'
    if not os.path.exists(build_path):
        os.mkdir(build_path)

    event_handler = ModifiedHandler()
    observer = Observer()
    observer.schedule(event_handler,
                      path=args[1] if args else '.', recursive=True)
    observer.start()
 
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()

ไฟล์ tests/CMakeLists.txt
cmake_minimum_required(VERSION 2.8.8)

set(PROJECT_NAME HelloGtest)
set(PROJECT_TEST_NAME ${PROJECT_NAME}-test)
project(${PROJECT_NAME} C CXX)


set(EXT_PROJECTS_DIR ${PROJECT_SOURCE_DIR}/external)
add_subdirectory(${EXT_PROJECTS_DIR}/gtest)

link_libraries(
)

file(GLOB_RECURSE ${PROJECT_NAME}_CXX_SRC RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "../src/*.cpp")
file(GLOB_RECURSE ${PROJECT_NAME}_CXX_TESTS RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "test*.cpp")

# remove main function file from test
list(REMOVE_ITEM ${PROJECT_NAME}_CXX_SRC ${CMAKE_CURRENT_SOURCE_DIR} "../src/main.cpp")

# complier
set (CMAKE_CXX_COMPILER g++)
add_definitions(-std=c++1y -pthread)
set (CMAKE_VERBOSE_MAKEFILE TRUE)

set (CMAKE_CXX_FLAGS_DEBUG, "-g -Wall")
set (CMAKE_CXX_FLAGS "-Wall")

include_directories ("${PROJECT_BINARY_DIR}" "${PROJECT_SOURCE_DIR}/../src"  "${PROJECT_SOURCE_DIR}/../tests")


add_executable (${PROJECT_TEST_NAME} ${${PROJECT_NAME}_CXX_TESTS} ${${PROJECT_NAME}_CXX_SRC})
# add_executable (${PROJECT_TEST_NAME} ${${PROJECT_NAME}_CXX_SRC})
add_dependencies(${PROJECT_TEST_NAME} googletest)
target_link_libraries (${PROJECT_TEST_NAME} 
    ${GTEST_LIBS_DIR}/libgtest.a
    ${GTEST_LIBS_DIR}/libgtest_main.a
    pthread
)

ไฟล์ tests/external/gtest/CMakeLists.txt
cmake_minimum_required(VERSION 2.8.8)
project(gtest_builder C CXX)
include(ExternalProject)

ExternalProject_Add(googletest
    SOURCE_DIR /usr/src/gtest
    CMAKE_ARGS -DCMAKE_ARCHIVE_OUTPUT_DIRECTORY_DEBUG:PATH=DebugLibs
               -DCMAKE_ARCHIVE_OUTPUT_DIRECTORY_RELEASE:PATH=ReleaseLibs
               -DCMAKE_CXX_FLAGS=${MSVC_COMPILER_DEFS}
               -Dgtest_force_shared_crt=ON
     PREFIX "${CMAKE_CURRENT_BINARY_DIR}"
# Disable install step
    INSTALL_COMMAND ""
)

# Specify include dir
ExternalProject_Get_Property(googletest source_dir)
set(GTEST_INCLUDE_DIRS ${source_dir}/include PARENT_SCOPE)

# Specify MainTest's link libraries
ExternalProject_Get_Property(googletest binary_dir)
set(GTEST_LIBS_DIR ${binary_dir} PARENT_SCOPE)

เวลาใช้งานก็แค่ รัน cpp-testrunner ทิ้งไว้ ทุกครั้งที่มีการบันทึกไฟล์ สคลิปต์ จะรัน cmake และ make ให้อัตโนมัติ พร้อมกับรัน Test Case ของไฟล์นั้นให้เลย 

ความคิดเห็น