Pengujian kode numerik

Pengujian rutin numerik dengan test-drive dan pFUnit serta disiplin pengujian pada CI.

Daftar isi

Rutin numerik memiliki sifat yang menjadikan pengujian otomatis penting: program tetap berjalan dan mencetak angka meskipun angka yang dicetak keliru. Topik ini membangun satu rangkaian pengujian (test suite) dengan pustaka test-drive pada proyek fpm. Rangkaian itu kemudian dijalankan terhadap implementasi yang sengaja dikelirukan, diperbaiki, dan dipakai sebagai gerbang pada continuous integration (CI). Kerangka pFUnit dibahas sebagai alternatif yang lebih lengkap. Seluruh kode tersedia di direktori topik ini dan setiap perintah dijalankan dari sana.

Struktur proyek pengujian#

Proyek pengujian memakai struktur fpm standar: fungsi yang diuji berada di src/, sedangkan berkas pengujian berada di test/. Manifest proyek mendeklarasikan test-drive sebagai dependensi git, sehingga fpm test dapat mengunduh dan memakainya tanpa pemasangan manual.

toml Contoh: fpm.toml proyek uji-rms
name = "uji-rms"
version = "0.1.0"

[dependencies]
test-drive = { git = "https://github.com/fortran-lang/test-drive" }

Fungsi yang diuji adalah rms, akar kuadrat rata-rata (root mean square) dari sebuah vektor. Metrik ini lazim dipakai untuk menakar besar kesalahan sebuah solusi numerik karena mengagregasi simpangan setiap elemen menjadi satu angka, dan nilai rms yang kecil membatasi simpangan tiap elemen karena kontribusi kuadrat satu elemen yang menyimpang jauh langsung menaikkan hasilnya. Untuk vektor konstanta seperti [5.0, 5.0], nilainya sama dengan nilai elemennya, sebuah sifat yang menyediakan kasus uji pertama yang mudah dinilai.

fortran rms.f90
module rms_mod
   implicit none
   private
   public :: rms
contains
   pure function rms(nilai) result(hasil)
      real, intent(in) :: nilai(:)
      real :: hasil
      hasil = norm2(nilai) / sqrt(real(size(nilai)))
   end function rms
end module rms_mod

Modul pengujian menyusun daftar kasus uji dengan new_unittest, dan setiap kasus menerima error_type yang diisi bila pemeriksaan gagal. Prosedur check membandingkan kondisi logika dan melampirkan pesan bila gagal. Nilai acuan ditulis sebagai konstanta, dan pemeriksaannya membandingkan selisih dengan toleransi ketat alih-alih menggunakan ==, karena kesamaan penuh nilai floating point tidak dapat diandalkan.

Toleransi 1.0e-6 yang dipakai kedua kasus bersifat mutlak karena nilai acuannya berorde satu, sedangkan kesalahan pembulatan single precision pada perhitungan semacam ini jauh di bawah angka itu. Untuk besaran yang dapat sangat besar atau sangat kecil, toleransi relatif yang menakar selisih terhadap nilai acuannya lebih tepat karena menuntut ketelitian yang seragam pada skala mana pun. Pilihan toleransi dengan demikian merupakan bagian dari spesifikasi pengujian, bukan sekadar angka tempelan pada perbandingan.

fortran cek_rms.f90
module cek_rms
   use testdrive, only : unittest_type, new_unittest
   use rms_mod, only : rms
   implicit none
   private
   public :: collect_suite
contains
   subroutine collect_suite(tests)
      type(unittest_type), allocatable, intent(out) :: tests(:)
      tests = [ new_unittest("cek_konstanta", cek_konstanta), &
         &      new_unittest("cek_dua_nilai", cek_dua_nilai) ]
   end subroutine collect_suite

   subroutine cek_konstanta(error)
      use testdrive, only : error_type, check
      type(error_type), allocatable, intent(out) :: error
      call check(error, abs(rms([5.0, 5.0]) - 5.0) < 1.0e-6, "rms vektor konstanta")
   end subroutine cek_konstanta

   subroutine cek_dua_nilai(error)
      use testdrive, only : error_type, check
      type(error_type), allocatable, intent(out) :: error
      call check(error, abs(rms([3.0, 4.0]) - 3.5355339) < 1.0e-6, "rms dua nilai")
   end subroutine cek_dua_nilai
end module cek_rms

Program pengemudi pada pengemudi.f90 mengumpulkan rangkaian pengujian dengan new_testsuite, menjalankannya lewat run_testsuite, dan menghentikan program dengan kode keluar bukan nol bila ada kasus yang gagal. Variabel stat dikumpulkan lintas seluruh rangkaian, sehingga satu kegagalan pada satu kasus pun tidak dapat lolos diam-diam. Kode keluar inilah yang nantinya dibaca oleh CI.

fortran pengemudi.f90
program pengemudi
   use, intrinsic :: iso_fortran_env, only : error_unit
   use testdrive, only : run_testsuite, new_testsuite, testsuite_type
   use cek_rms, only : collect_suite
   implicit none
   integer :: stat, is
   type(testsuite_type), allocatable :: testsuites(:)
   character(len=*), parameter :: fmt = '("Testing: ", a)'
   testsuites = [ new_testsuite("cek_rms", collect_suite) ]
   stat = 0
   do is = 1, size(testsuites)
      write(error_unit, fmt) testsuites(is)%name
      call run_testsuite(testsuites(is)%collect, error_unit, stat)
   end do
   if (stat > 0) then
      write(error_unit, '(i0, 1x, a)') stat, "test(s) failed!"
      error stop 1
   end if
end program pengemudi

Menangkap implementasi yang keliru#

Cara paling meyakinkan untuk melihat nilai sebuah pengujian adalah menjalankannya terhadap kode yang keliru. Blok berikut menyusun proyek pada direktori sementara, tetapi dengan rms yang menghitung rata-rata aritmetika alih-alih akar kuadrat rata-rata. Kekeliruan yang ditanam sengaja dipilih yang dekat dengan aslinya: rata-rata aritmetika tetap menghasilkan angka yang tampak masuk akal, persis seperti kutu (bug) sungguhan yang biasanya memunculkan keluaran wajar alih-alih angka mustahil. Baris kemajuan bangun fpm disembunyikan dengan saringan grep karena urutannya bergantung pada penjadwalan bangun, dan hanya baris hasil pengujian yang ditampilkan.

bash
# susun proyek pengujian pada direktori sementara
rm -rf /tmp/uji-rms && fpm new /tmp/uji-rms > /dev/null 2>&1
rm /tmp/uji-rms/test/check.f90
cp cek_rms.f90 pengemudi.f90 /tmp/uji-rms/test/
cat > /tmp/uji-rms/fpm.toml <<'EOF'
name = "uji-rms"
version = "0.1.0"

[dependencies]
test-drive = { git = "https://github.com/fortran-lang/test-drive" }
EOF
# rms yang keliru: rata-rata aritmetika
cat > /tmp/uji-rms/src/rms.f90 <<'EOF'
module rms_mod
   implicit none
   private
   public :: rms
contains
   pure function rms(nilai) result(hasil)
      real, intent(in) :: nilai(:)
      real :: hasil
      hasil = sum(nilai) / real(size(nilai))
   end function rms
end module rms_mod
EOF
# jalankan seluruh kasus uji
(cd /tmp/uji-rms && fpm test 2>&1 | grep -E "Testing|PASSED|FAILED|failed|STOP")
text Keluaran
Testing: cek_rms
       ... cek_konstanta [PASSED]
       ... cek_dua_nilai [FAILED]
1 test(s) failed!
ERROR STOP 1
<ERROR> *cmd_run*:stopping due to failed executions
STOP 1

Keluaran tersebut memperlihatkan dua hal sekaligus. Kasus cek_dua_nilai menangkap kekeliruan karena akar kuadrat rata-rata dari [3, 4] adalah 3,5355, sedangkan rata-rata aritmetikanya 3,5. Sebaliknya kasus cek_konstanta tetap lolos pada kode yang keliru karena rata-rata aritmetika dari vektor konstanta memang sama dengan rms-nya, sehingga satu kasus uji yang lolos tidak pernah menjadi bukti kode yang benar. Program pengemudi juga berhenti dengan ERROR STOP 1, dan fpm meneruskannya sebagai kode keluar bukan nol.

Perbaikan dan disiplin pengujian pada CI#

Salinan rms.f90 yang benar dari direktori topik menggantikan versi keliru, dan seluruh rangkaian dijalankan ulang.

bash
# ganti dengan rms yang benar, lalu jalankan ulang pengujian
cp rms.f90 /tmp/uji-rms/src/rms.f90
(cd /tmp/uji-rms && fpm test 2>&1 | grep -E "Testing|PASSED|FAILED|failed|STOP")
text Keluaran
Testing: cek_rms
       ... cek_konstanta [PASSED]
       ... cek_dua_nilai [PASSED]

Kode keluar fpm test inilah yang menjadi gerbang CI: perubahan kode hanya digabungkan (merge) bila seluruh kasus uji lolos. Gerbang semacam ini menjawab kelemahan pemeriksaan manual: perbaikan yang menyelesaikan satu jalur dapat merusak jalur lain tanpa disadari penulisnya, dan tanpa pengujian yang dijalankan ulang otomatis, kerusakan itu baru ketahuan jauh setelah perubahan penyebabnya masuk. Pada workflow berbasis GitHub Actions, langkahnya cukup satu baris setelah lingkungan fpm disiapkan.

yaml Contoh: langkah pengujian pada CI
      - run: fpm test

Kerangka pFUnit menawarkan pendekatan yang lebih lengkap untuk proyek besar. Fiturnya mencakup pernyataan siap pakai assertEquals untuk nilai floating point dengan toleransi, pengujian prosedur dengan argumen terikat (mock), dan pelaporan dalam format JUnit yang dibaca banyak server CI. pFUnit memakai pra-pemroses (preprocessor) untuk memperluas makro pernyataannya, sehingga pemasangannya lebih berat daripada test-drive yang cukup dideklarasikan pada fpm.toml.

Kesalahan umum#

  • Membandingkan nilai floating point dengan ==. Perubahan urutan operasi yang tak terhindarkan antarkompiler membuat kesamaan ketat menjadi rapuh. Bandingkan selisih terhadap toleransi yang dinyatakan.
  • Menulis kasus uji yang tidak pernah bisa gagal, misalnya memanggil prosedur tanpa memeriksa hasilnya. Kasus seperti itu menaikkan jumlah kasus tanpa menaikkan jaminan apa pun.
  • Mengabaikan kode keluar pengemudi. Tanpa error stop saat ada kegagalan, CI melihat pengujian yang gagal sebagai lolos dan menggabungkan kode yang keliru.

Ringkasan#

  • test-drive menyusun kasus uji lewat new_unittest dan new_testsuite, dengan setiap kasus menerima error_type yang diisi prosedur check bila pemeriksaan gagal.
  • Rangkaian pada topik ini menangkap implementasi rms yang keliru melalui kasus cek_dua_nilai, tetapi juga menunjukkan bahwa kasus cek_konstanta lolos pada kode yang keliru.
  • Kode keluar fpm test menjadi gerbang CI, dan pFUnit menjadi alternatif untuk proyek yang butuh pernyataan numerik dan pelaporan JUnit.

Referensi#