Test Suite

Program yang membuktikan fitur bekerja dan gagal dengan baik saat masukan salah.

Daftar isi

Test suite adalah program yang membuktikan fitur bekerja dan gagal dengan baik saat masukan salah. Topik ini menyusun test suite untuk fitur for-loop rentang menggunakan kerangka unittest bawaan Python, dengan kasus positif yang memverifikasi keluaran fitur dan kasus negatif yang memverifikasi compiler menolak masukan invalid. Berkas test_for.py di samping topik ini berisi compiler yang ditanam serta tujuh test case. Setelah membaca topik ini, pembaca dapat menyusun test suite yang menutup perilaku benar dan salah, lalu menjalankannya dengan satu perintah.

Struktur test suite#

Test suite yang baik menutup dua sisi, yaitu sisi positif dan sisi negatif. Sisi positif memverifikasi keluaran fitur untuk masukan valid, sehingga perubahan yang merusak keluaran segera terdeteksi. Sisi negatif memverifikasi compiler menolak masukan invalid dengan pesan error yang sesuai, sehingga compiler yang diam-diam menerima masukan salah segera terdeteksi. Kedua sisi sama pentingnya, sebab fitur yang hanya diuji positif dapat lolos saat masukan invalid diterima tanpa error.

Kerangka unittest bawaan Python dipakai sebab tidak membutuhkan dependensi luar dan tersedia di sandbox Python standar. Setiap test case adalah metode pada kelas TestCase yang diawali test_. Test positif memakai assertEqual untuk membandingkan keluaran compiler dengan nilai yang diharapkan. Test negatif memakai assertRaises untuk memastikan compiler melempar CompilerError pada masukan invalid. Dengan demikian, kegagalan test adalah sinyal yang jelas, yaitu positif yang gagal berarti fitur rusak, sedangkan negatif yang gagal berarti compiler menerima masukan yang seharusnya ditolak.

Test negatif berfungsi sebagai bukti bahwa test suite menangkap perilaku salah. Bila compiler sengaja diubah untuk menerima masukan invalid, test negatif akan gagal, sehingga regresi terdeteksi sebelum kode mencapai pengguna. Dengan demikian, test suite tidak hanya membuktikan fitur bekerja, tetapi juga membuktikan fitur gagal dengan baik saat masukan salah, sesuai pesan error yang ditetapkan pada spesifikasi_for.md.

Setiap test negatif dapat ditelusuri ke satu contoh program invalid pada spesifikasi_for.md. Test test_error_tanpa_in menelusuri contoh for i 1..5 { print i } pada bagian contoh invalid, yang seharusnya memunculkan error parse token 'in' diharapkan. Test test_error_tanpa_kurawal_pembuka menelusuri contoh for i in 1..5 print i }, yang seharusnya memunculkan error parse token '{' diharapkan. Test test_error_iterable_bukan_rentang menelusuri contoh for i in 5 { print i }, yang seharusnya memunculkan error semantik for-in mengharapkan rentang (a..b). Penelusuran ini menjaga test suite tetap sejalan dengan kontrak, sehingga perubahan spesifikasi akan memaksa pembaruan test dan sebaliknya.

Test case untuk for-loop#

Berkas test_for.py di samping topik ini berisi compiler yang ditanam serta tujuh test case. Compiler yang ditanam adalah versi ringkas dari compiler pada implementasi_for.py, dengan logika yang sama tetapi ditanam agar test suite mandiri. Tujuh test case terbagi atas empat positif dan tiga negatif. Positif menguji loop sederhana, akumulasi, pengikatan di badan loop, dan rentang kosong. Negatif menguji kata kunci in yang hilang, kurung kurawal pembuka blok yang hilang, dan ekspresi setelah in yang bukan Range.

python test_for.py
"""Test suite untuk fitur for-loop rentang.

Berkas ini bersifat mandiri: compiler yang diuji ditanam di sini agar
test suite dapat dijalankan tanpa bergantung pada berkas di bundle lain.
Pada proyek nyata, compiler diimpor sebagai modul terpisah, tetapi
struktur test tetap sama, yaitu satu kelas TestCase per fitur dengan
kasus positif dan negatif.

Jalankan dengan:
  python3 -m unittest test_for
atau:
  python3 test_for.py

Test positif memverifikasi keluaran fitur untuk masukan valid.
Test negatif memverifikasi compiler menolak masukan invalid dengan
pesan error yang sesuai, sehingga test suite membuktikan bahwa
perilaku salah ditolak, bukan hanya perilaku benar yang diterima.
"""
import unittest


# ===========================================================================
# Compiler yang diuji (ditanam, versi ringkas dari implementasi_for.py)
# ===========================================================================

class CompilerError(Exception):
    def __init__(self, msg, pos=None):
        super().__init__(msg)
        self.msg = msg
        self.pos = pos


KEYWORDS = {"let": "LET", "print": "PRINT", "for": "FOR", "in": "IN"}
SINGLE = {"=": "EQ", "+": "PLUS", "-": "MINUS", "*": "STAR",
          "(": "LPAREN", ")": "RPAREN", "{": "LBRACE", "}": "RBRACE"}


class Token:
    __slots__ = ("kind", "value", "pos")

    def __init__(self, kind, value, pos):
        self.kind = kind
        self.value = value
        self.pos = pos


def lex(src):
    tokens = []
    i = 0
    n = len(src)
    while i < n:
        c = src[i]
        if c in " \t\r\n":
            i += 1
            continue
        if c.isdigit():
            j = i
            while j < n and src[j].isdigit():
                j += 1
            tokens.append(Token("INT", int(src[i:j]), i))
            i = j
            continue
        if c.isalpha() or c == "_":
            j = i
            while j < n and (src[j].isalnum() or src[j] == "_"):
                j += 1
            word = src[i:j]
            tokens.append(Token(KEYWORDS.get(word, "IDENT"), word, i))
            i = j
            continue
        if c == "." and i + 1 < n and src[i + 1] == ".":
            tokens.append(Token("DOTDOT", "..", i))
            i += 2
            continue
        if c in SINGLE:
            tokens.append(Token(SINGLE[c], c, i))
            i += 1
            continue
        raise CompilerError(f"lexer: karakter tidak dikenal {c!r}", i)
    tokens.append(Token("EOF", None, i))
    return tokens


class Program:
    def __init__(self, stmts):
        self.stmts = stmts


class Let:
    def __init__(self, name, expr, pos):
        self.name = name; self.expr = expr; self.pos = pos


class Print:
    def __init__(self, expr, pos):
        self.expr = expr; self.pos = pos


class Assign:
    def __init__(self, name, expr, pos):
        self.name = name; self.expr = expr; self.pos = pos


class For:
    def __init__(self, var, expr, body, pos):
        self.var = var; self.expr = expr; self.body = body; self.pos = pos


class Block:
    def __init__(self, stmts):
        self.stmts = stmts


class BinOp:
    def __init__(self, op, left, right, pos):
        self.op = op; self.left = left; self.right = right; self.pos = pos


class Range:
    def __init__(self, lo, hi, pos):
        self.lo = lo; self.hi = hi; self.pos = pos


class Int:
    def __init__(self, value, pos):
        self.value = value; self.pos = pos


class Ident:
    def __init__(self, name, pos):
        self.name = name; self.pos = pos


class Parser:
    def __init__(self, tokens):
        self.toks = tokens
        self.i = 0

    def peek(self):
        return self.toks[self.i]

    def advance(self):
        t = self.toks[self.i]
        self.i += 1
        return t

    def expect(self, kind):
        t = self.peek()
        if t.kind != kind:
            raise CompilerError(
                f"parser: token {kind} diharapkan, mendapat {t.kind}", t.pos)
        return self.advance()

    def parse_program(self):
        stmts = []
        while self.peek().kind != "EOF":
            stmts.append(self.parse_stmt())
        return Program(stmts)

    def parse_stmt(self):
        t = self.peek()
        if t.kind == "LET":
            return self.parse_let()
        if t.kind == "PRINT":
            return self.parse_print()
        if t.kind == "FOR":
            return self.parse_for()
        if t.kind == "LBRACE":
            return self.parse_block()
        if t.kind == "IDENT":
            return self.parse_assign()
        raise CompilerError(
            f"parser: pernyataan tidak dikenal, mendapat {t.kind}", t.pos)

    def parse_assign(self):
        t = self.expect("IDENT")
        self.expect("EQ")
        return Assign(t.value, self.parse_expr(), t.pos)

    def parse_let(self):
        k = self.expect("LET")
        name = self.expect("IDENT").value
        self.expect("EQ")
        return Let(name, self.parse_expr(), k.pos)

    def parse_print(self):
        k = self.expect("PRINT")
        return Print(self.parse_expr(), k.pos)

    def parse_for(self):
        k = self.expect("FOR")
        var = self.expect("IDENT").value
        self.expect("IN")
        expr = self.parse_expr()
        body = self.parse_block()
        return For(var, expr, body, k.pos)

    def parse_block(self):
        self.expect("LBRACE")
        stmts = []
        while self.peek().kind != "RBRACE":
            stmts.append(self.parse_stmt())
        self.expect("RBRACE")
        return Block(stmts)

    def parse_expr(self):
        return self.parse_range()

    def parse_range(self):
        lo = self.parse_arith()
        if self.peek().kind == "DOTDOT":
            pos = self.advance().pos
            return Range(lo, self.parse_arith(), pos)
        return lo

    def parse_arith(self):
        left = self.parse_term()
        while self.peek().kind in ("PLUS", "MINUS"):
            t = self.advance()
            left = BinOp(t.value, left, self.parse_term(), t.pos)
        return left

    def parse_term(self):
        left = self.parse_factor()
        while self.peek().kind == "STAR":
            t = self.advance()
            left = BinOp(t.value, left, self.parse_factor(), t.pos)
        return left

    def parse_factor(self):
        t = self.peek()
        if t.kind == "INT":
            self.advance()
            return Int(t.value, t.pos)
        if t.kind == "IDENT":
            self.advance()
            return Ident(t.value, t.pos)
        if t.kind == "LPAREN":
            self.advance()
            e = self.parse_expr()
            self.expect("RPAREN")
            return e
        raise CompilerError(
            f"parser: ekspresi diharapkan, mendapat {t.kind}", t.pos)


class Semantik:
    def __init__(self):
        self.scopes = [{}]

    def declare(self, name, pos):
        if name in self.scopes[-1]:
            raise CompilerError(
                f"semantik: nama {name!r} sudah dideklarasikan di scope ini", pos)
        self.scopes[-1][name] = "int"

    def lookup(self, name, pos):
        for s in reversed(self.scopes):
            if name in s:
                return s[name]
        raise CompilerError(f"semantik: nama {name!r} tidak dideklarasikan", pos)

    def check(self, node):
        if isinstance(node, Program):
            for s in node.stmts:
                self.check(s)
        elif isinstance(node, Block):
            self.scopes.append({})
            for s in node.stmts:
                self.check(s)
            self.scopes.pop()
        elif isinstance(node, Let):
            self.check_expr(node.expr)
            self.declare(node.name, node.pos)
        elif isinstance(node, Assign):
            self.lookup(node.name, node.pos)
            self.check_expr(node.expr)
        elif isinstance(node, Print):
            self.check_expr(node.expr)
        elif isinstance(node, For):
            if not isinstance(node.expr, Range):
                raise CompilerError(
                    "semantik: for-in mengharapkan rentang (a..b)", node.pos)
            self.check_expr(node.expr.lo)
            self.check_expr(node.expr.hi)
            self.scopes.append({})
            self.declare(node.var, node.pos)
            self.check(node.body)
            self.scopes.pop()

    def check_expr(self, node):
        if isinstance(node, Int):
            return "int"
        if isinstance(node, Ident):
            return self.lookup(node.name, node.pos)
        if isinstance(node, BinOp):
            lt = self.check_expr(node.left)
            rt = self.check_expr(node.right)
            if lt != "int" or rt != "int":
                raise CompilerError(f"semantik: operand {node.op} harus int", node.pos)
            return "int"
        if isinstance(node, Range):
            raise CompilerError(
                "semantik: rentang hanya boleh muncul pada for-in", node.pos)


class Codegen:
    def __init__(self):
        self.code = []
        self.labels = 0
        self.tmps = 0

    def new_label(self):
        self.labels += 1
        return f"L{self.labels}"

    def new_tmp(self):
        self.tmps += 1
        return f"__tmp{self.tmps}"

    def emit(self, *instr):
        self.code.append(instr)

    def gen(self, node):
        for s in node.stmts:
            self.gen_stmt(s)
        self.emit("HALT")

    def gen_stmt(self, node):
        if isinstance(node, Let):
            self.gen_expr(node.expr)
            self.emit("STORE", node.name)
        elif isinstance(node, Assign):
            self.gen_expr(node.expr)
            self.emit("ASSIGN", node.name)
        elif isinstance(node, Print):
            self.gen_expr(node.expr)
            self.emit("PRINT")
        elif isinstance(node, Block):
            self.emit("PUSH_SCOPE")
            for s in node.stmts:
                self.gen_stmt(s)
            self.emit("POP_SCOPE")
        elif isinstance(node, For):
            end = self.new_tmp()
            self.emit("PUSH_SCOPE")
            self.gen_expr(node.expr.hi)
            self.emit("STORE", end)
            self.gen_expr(node.expr.lo)
            self.emit("STORE", node.var)
            lc = self.new_label()
            le = self.new_label()
            self.emit("LABEL", lc)
            self.emit("LOAD", node.var)
            self.emit("LOAD", end)
            self.emit("LT")
            self.emit("JMP_IF_FALSE", le)
            self.gen_stmt(node.body)
            self.emit("INC", node.var)
            self.emit("JMP", lc)
            self.emit("LABEL", le)
            self.emit("POP_SCOPE")

    def gen_expr(self, node):
        if isinstance(node, Int):
            self.emit("PUSH_INT", node.value)
        elif isinstance(node, Ident):
            self.emit("LOAD", node.name)
        elif isinstance(node, BinOp):
            self.gen_expr(node.left)
            self.gen_expr(node.right)
            self.emit({"+": "ADD", "-": "SUB", "*": "MUL"}[node.op])


class VM:
    def __init__(self, code):
        self.code = code
        self.labels = {}
        for i, instr in enumerate(self.code):
            if instr[0] == "LABEL":
                self.labels[instr[1]] = i
        self.stack = []
        self.env = [{}]
        self.out = []

    def load(self, name):
        for s in reversed(self.env):
            if name in s:
                return s[name]
        raise CompilerError(f"vm: variabel {name!r} tidak terdefinisi")

    def store(self, name, val):
        self.env[-1][name] = val

    def assign(self, name, val):
        for s in reversed(self.env):
            if name in s:
                s[name] = val
                return
        self.env[-1][name] = val

    def run(self):
        pc = 0
        while pc < len(self.code):
            op = self.code[pc][0]
            instr = self.code[pc]
            if op == "HALT":
                break
            elif op == "PUSH_INT":
                self.stack.append(instr[1]); pc += 1
            elif op == "LOAD":
                self.stack.append(self.load(instr[1])); pc += 1
            elif op == "STORE":
                self.store(instr[1], self.stack.pop()); pc += 1
            elif op == "ASSIGN":
                self.assign(instr[1], self.stack.pop()); pc += 1
            elif op == "ADD":
                b = self.stack.pop(); a = self.stack.pop(); self.stack.append(a + b); pc += 1
            elif op == "SUB":
                b = self.stack.pop(); a = self.stack.pop(); self.stack.append(a - b); pc += 1
            elif op == "MUL":
                b = self.stack.pop(); a = self.stack.pop(); self.stack.append(a * b); pc += 1
            elif op == "LT":
                b = self.stack.pop(); a = self.stack.pop(); self.stack.append(1 if a < b else 0); pc += 1
            elif op == "INC":
                self.store(instr[1], self.load(instr[1]) + 1); pc += 1
            elif op == "PRINT":
                self.out.append(str(self.stack.pop())); pc += 1
            elif op == "JMP":
                pc = self.labels[instr[1]]
            elif op == "JMP_IF_FALSE":
                cond = self.stack.pop()
                pc = self.labels[instr[1]] if not cond else pc + 1
            elif op == "PUSH_SCOPE":
                self.env.append({}); pc += 1
            elif op == "POP_SCOPE":
                self.env.pop(); pc += 1
            elif op == "LABEL":
                pc += 1
            else:
                raise CompilerError(f"vm: instruksi tidak dikenal {op}")
        return self.out


def run_src(src):
    tokens = lex(src)
    ast = Parser(tokens).parse_program()
    Semantik().check(ast)
    cg = Codegen()
    cg.gen(ast)
    return VM(cg.code).run()


# ===========================================================================
# Test suite
# ===========================================================================

class TestForLoop(unittest.TestCase):
    """Test positif dan negatif untuk fitur for-loop rentang."""

    def test_loop_sederhana(self):
        # Cetak tiap iterasi rentang 1..4 (eksklusif ujung kanan).
        out = run_src("for i in 1..4 {\n  print i\n}")
        self.assertEqual(out, ["1", "2", "3"])

    def test_akumulasi(self):
        # Akumulasi 1..5 = 1 + 2 + 3 + 4 = 10.
        src = (
            "let total = 0\n"
            "for i in 1..5 {\n"
            "  total = total + i\n"
            "}\n"
            "print total"
        )
        self.assertEqual(run_src(src), ["10"])

    def test_body_dengan_let(self):
        # let di badan loop dilingkupkan ke blok badan; x segar tiap iterasi.
        src = (
            "for i in 1..3 {\n"
            "  let x = i * 2\n"
            "  print x\n"
            "}"
        )
        self.assertEqual(run_src(src), ["2", "4"])

    def test_rentang_kosong(self):
        # Rentang a..b dengan a >= b menyatakan himpunan kosong; badan tak jalan.
        src = (
            "for i in 5..5 {\n"
            "  print i\n"
            "}\n"
            "print 99"
        )
        self.assertEqual(run_src(src), ["99"])

    def test_error_tanpa_in(self):
        # Kata kunci 'in' hilang setelah variabel loop.
        with self.assertRaises(CompilerError):
            run_src("for i 1..5 {\n  print i\n}")

    def test_error_tanpa_kurawal_pembuka(self):
        # Kurung kurawal pembuka blok hilang.
        with self.assertRaises(CompilerError):
            run_src("for i in 1..5 print i }")

    def test_error_iterable_bukan_rentang(self):
        # Ekspresi setelah 'in' bukan Range.
        with self.assertRaises(CompilerError):
            run_src("for i in 5 {\n  print i\n}")


if __name__ == "__main__":
    unittest.main()

Test positif membandingkan keluaran compiler dengan daftar string yang diharapkan. Test test_loop_sederhana menjalankan for i in 1..4 { print i } dan membandingkan keluaran dengan ["1", "2", "3"], yaitu tiga iterasi sesuai aturan ujung kanan eksklusif. Test test_rentang_kosong menjalankan for i in 5..5 { print i } dan membandingkan keluaran dengan ["99"], yaitu hanya keluaran pernyataan sesudah loop sebab badan loop tidak dijalankan. Test negatif memakai assertRaises(CompilerError) untuk memastikan compiler menolak masukan invalid. Test test_error_tanpa_in memastikan for i 1..5 { print i } melempar CompilerError sebab kata kunci in hilang, sedangkan test_error_iterable_bukan_rentang memastikan for i in 5 { print i } melempar CompilerError sebab ekspresi setelah in bukan Range.

Menjalankan test suite#

Test suite dijalankan dengan satu perintah, yaitu python3 -m unittest test_for, atau langsung python3 test_for.py. Kedua perintah menghasilkan keluaran yang sama, sebab test_for.py memanggil unittest.main() pada __main__. Keluaran berikut diperoleh dengan menjalankan test suite di sandbox.

text Keluaran
.......
----------------------------------------------------------------------
Ran 7 tests in 0.001s

OK

Keluaran menunjukkan tujuh titik, yaitu satu per test yang lulus, diikuti ringkasan jumlah test dan status OK. Tidak ada test yang gagal, yang berarti fitur for-loop bekerja untuk keempat masukan valid dan menolak ketiga masukan invalid dengan benar. Test negatif yang lulus adalah bukti bahwa test suite menangkap perilaku salah, yaitu compiler melempar CompilerError tepat pada masukan yang seharusnya ditolak. Bila compiler sengaja diubah untuk menerima masukan invalid, salah satu test negatif akan gagal, sehingga regresi terdeteksi sebelum kode mencapai pengguna. Disiplin ini melengkapi test positif, sehingga test suite tidak hanya membuktikan fitur bekerja, tetapi juga membuktikan fitur gagal dengan baik saat masukan salah.

Ringkasan#

  • Test suite menutup dua sisi, yaitu positif yang memverifikasi keluaran fitur dan negatif yang memverifikasi compiler menolak masukan invalid.
  • Kerangka unittest bawaan Python dipakai sebab tidak butuh dependensi luar, dengan test positif memakai assertEqual dan test negatif memakai assertRaises.
  • Tujuh test case terbagi atas empat positif dan tiga negatif, dengan compiler yang ditanam agar test suite mandiri.
  • Test negatif yang lulus adalah bukti test suite menangkap perilaku salah, sehingga compiler yang diam-diam menerima masukan invalid segera terdeteksi.

Referensi#