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.
"""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.
.......
----------------------------------------------------------------------
Ran 7 tests in 0.001s
OKKeluaran 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
unittestbawaan Python dipakai sebab tidak butuh dependensi luar, dengan test positif memakaiassertEqualdan test negatif memakaiassertRaises. - 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#
- unittest — Unit testing framework — Python documentation
— Dokumentasi resmi kerangka
unittestyang dipakai pada test suite. - Crafting Interpreters — Bob Nystrom — Bab tentang pengujian interpreter, termasuk test positif dan negatif.
- The Rust Reference: For loops and ranges — Rust Project
— Sintaksis rentang
a..byang menjadi rujukan kasus uji positif dan negatif.