Tes murni dalam PHP dan PHPUnit


Ada banyak alat dalam ekosistem PHP yang menyediakan pengujian PHP yang nyaman. Salah satu yang paling terkenal adalah PHPUnit , yang hampir merupakan sinonim untuk pengujian dalam bahasa ini. Namun, tidak banyak yang ditulis tentang metode pengujian yang baik. Ada banyak pilihan mengapa dan kapan menulis tes, tes seperti apa, dan sebagainya. Tetapi jujur ​​saja, tidak masuk akal untuk menulis ujian jika Anda tidak bisa membacanya nanti .

Tes adalah jenis dokumentasi khusus. Seperti yang saya tulis sebelumnya tentang TDD dalam PHP , tes akan selalu (atau setidaknya harus) dengan jelas berbicara tentang apa tugas dari sepotong kode tertentu.

Jika satu tes tidak dapat mengungkapkan ide ini, maka tes tersebut buruk.

Saya telah menyiapkan serangkaian teknik yang akan membantu pengembang PHP menulis tes yang baik, mudah dibaca dan bermanfaat.

Mari kita mulai dengan dasar-dasarnya


Ada serangkaian teknik standar yang banyak diikuti tanpa pertanyaan. Saya akan menyebutkan banyak dari mereka dan mencoba menjelaskan mengapa mereka dibutuhkan.

1. Tes tidak boleh mengandung operasi input-output


Alasan utama : operasi I / O lambat dan tidak dapat diandalkan.

Lambat : walaupun Anda memiliki perangkat keras terbaik di dunia, I / O masih akan lebih lambat daripada akses memori. Tes harus selalu bekerja cepat, jika tidak orang akan menjalankannya terlalu jarang.

Tidak dapat diandalkan : beberapa file, binari, soket, folder, dan catatan DNS mungkin tidak tersedia di beberapa mesin yang Anda uji. Semakin Anda mengandalkan pengujian untuk I / O, semakin banyak tes Anda terkait dengan infrastruktur.

Apa yang terkait dengan operasi I / O:

  • Membaca dan menulis file.
  • Panggilan jaringan.
  • Panggilan ke proses eksternal (menggunakan exec , proc_open , dll.).

Ada situasi ketika kehadiran operasi input-output memungkinkan Anda untuk menulis tes lebih cepat. Tapi hati-hati: periksa apakah operasi seperti itu bekerja pada mesin Anda untuk pengembangan, perakitan dan penyebaran, jika tidak, Anda mungkin memiliki masalah serius.

Mengisolasi tes sehingga mereka tidak membutuhkan operasi I / O: Saya telah memberikan solusi arsitektur di bawah ini yang mencegah tes dari melakukan operasi I / O dengan berbagi tanggung jawab antara antarmuka.

Contoh:

 public function getPeople(): array { $rawPeople = file_get_contents( 'people.json' ) ?? '[]'; return json_decode( $rawPeople, true ); } 

Ketika Anda mulai menguji menggunakan metode ini, file lokal akan dibuat, dan dari waktu ke waktu snapshot akan dibuat:

 public function testGetPeopleReturnsPeopleList(): void { $people = $this->peopleService ->getPeople(); // assert it contains people } 

Untuk melakukan ini, kita perlu mengkonfigurasi prasyarat untuk menjalankan tes. Sepintas, semuanya terlihat masuk akal, tetapi kenyataannya mengerikan.

Melewati tes karena fakta bahwa prasyarat tidak terpenuhi tidak menjamin kualitas perangkat lunak kami. Ini hanya akan menyembunyikan bug!

Kami memperbaiki situasi : kami mengisolasi operasi I / O dengan mengalihkan tanggung jawab ke antarmuka.

 // extract the fetching // logic to a specialized // interface interface PeopleProvider { public function getPeople(): array; } // create a concrete implementation class JsonFilePeopleProvider implements PeopleProvider { private const PEOPLE_JSON = 'people.json'; public function getPeople(): array { $rawPeople = file_get_contents( self::PEOPLE_JSON ) ?? '[]'; return json_decode( $rawPeople, true ); } } class PeopleService { // inject via __construct() private PeopleProvider $peopleProvider; public function getPeople(): array { return $this->peopleProvider ->getPeople(); } } 

Sekarang saya tahu bahwa JsonFilePeopleProvider akan menggunakan I / O dalam hal apa pun.

Alih-alih file_get_contents() Anda dapat menggunakan lapisan abstraksi seperti sistem file Flysystem , yang dengan mudah membuat stubs.

Lalu mengapa kita membutuhkan PeopleService ? Pertanyaan yang bagus Untuk ini, tes diperlukan: untuk menantang arsitektur dan menghapus kode yang tidak berguna.

2. Tes harus sadar dan bermakna.


Alasan utama : tes adalah bentuk dokumentasi. Jaga agar jelas, ringkas, dan mudah dibaca.

Kejelasan dan singkatnya : tidak berantakan, tidak ada ribuan baris bertopik, tidak ada urutan pernyataan.

Keterbacaan : Tes harus menceritakan sebuah kisah. Struktur "diberikan, kapan, lalu" sangat bagus untuk ini.

Karakteristik tes yang baik dan mudah dibaca:

  • Berisi hanya panggilan yang diperlukan ke metode assert (lebih disukai satu).
  • Dia dengan sangat jelas menjelaskan apa yang harus terjadi dalam kondisi tertentu.
  • Ini menguji hanya satu cabang dari eksekusi metode.
  • Dia tidak membuat rintisan untuk seluruh alam semesta demi pernyataan apa pun.

Penting untuk dicatat bahwa jika implementasi Anda mengandung ekspresi bersyarat, operator transisi, atau loop, maka semuanya harus secara eksplisit dicakup oleh pengujian. Misalnya, agar jawaban awal selalu berisi tes.

Saya ulangi: ini bukan masalah cakupan, tetapi dokumentasi.

Berikut ini adalah contoh dari tes yang membingungkan:

 public function testCanFly(): void { $noWings = new Person(0); $this->assertEquals( false, $noWings->canFly() ); $singleWing = new Person(1); $this->assertTrue( !$singleWing->canFly() ); $twoWings = new Person(2); $this->assertTrue( $twoWings->canFly() ); } 

Mari kita adaptasi format "diberikan kapan, lalu" dan lihat apa yang terjadi:

 public function testCanFly(): void { // Given $person = $this->givenAPersonHasNoWings(); // Then $this->assertEquals( false, $person->canFly() ); // Further cases... } private function givenAPersonHasNoWings(): Person { return new Person(0); } 

Seperti bagian "Diberi", "kapan" dan "kemudian" dapat ditransfer ke metode pribadi. Ini akan membuat tes Anda lebih mudah dibaca.

assertEquals berantakan tidak berguna. Orang yang membaca ini harus melacak pernyataan untuk memahami apa artinya.

Menggunakan pernyataan spesifik akan membuat tes Anda lebih mudah dibaca. assertTrue() harus menerima variabel Boolean, bukan ekspresi seperti canFly() !== true .

Pada contoh sebelumnya, kami mengganti assertEquals antara false dan $person->canFly() dengan assertFalse sederhana:

 // ... $person = $this->givenAPersonHasNoWings(); $this->assertFalse( $person->canFly() ); // Further cases... 

Sekarang semuanya sangat jelas! Jika seseorang tidak memiliki sayap, dia pasti tidak bisa terbang! Baca seperti puisi

Sekarang bagian "Kasus selanjutnya", yang muncul dua kali dalam teks kami, merupakan indikasi yang jelas bahwa tes membuat terlalu banyak pernyataan. Metode testCanFly() sama sekali tidak berguna.

Mari kita tingkatkan tes lagi:

 public function testCanFlyIsFalsyWhenPersonHasNoWings(): void { $person = $this->givenAPersonHasNoWings(); $this->assertFalse( $person->canFly() ); } public function testCanFlyIsTruthyWhenPersonHasTwoWings(): void { $person = $this->givenAPersonHasTwoWings(); $this->assertTrue( $person->canFly() ); } // ... 

Kami bahkan dapat mengganti nama metode pengujian sehingga cocok dengan skenario nyata, misalnya, di testPersonCantFlyWithoutWings , tetapi semuanya testPersonCantFlyWithoutWings cocok untuk saya.

3. Tes tidak harus tergantung pada tes lain


Alasan utama : tes harus berjalan dan berjalan dengan sukses dalam urutan apa pun.

Saya tidak melihat alasan yang cukup untuk membuat interkoneksi antar tes. Baru-baru ini saya diminta untuk melakukan tes fungsi login, saya akan memberikannya di sini sebagai contoh yang baik.

Tes harus:

  • Hasilkan token JWT untuk masuk.
  • Jalankan fungsi login.
  • Menyetujui perubahan status.

Seperti ini:

 public function testGenerateJWTToken(): void { // ... $token $this->token = $token; } // @depends testGenerateJWTToken public function testExecuteAnAmazingFeature(): void { // Execute using $this->token } // @depends testExecuteAnAmazingFeature public function testStateIsBlah(): void { // Poll for state changes on // Logged-in interface } 

Ini buruk karena beberapa alasan:

  • PHPUnit tidak dapat menjamin urutan eksekusi ini.
  • Tes harus dapat berjalan secara independen.
  • Tes paralel mungkin gagal secara acak.

Cara termudah untuk menyiasatinya adalah dengan menggunakan skema yang diberikan, kapan, kemudian. Jadi tes akan lebih bijaksana, mereka akan bercerita, jelas menunjukkan ketergantungan mereka, menjelaskan fungsi yang sedang diuji.

 public function testAmazingFeatureChangesState(): void { // Given $token = $this->givenImAuthenticated(); // When $this->whenIExecuteMyAmazingFeature( $token ); $newState = $this->pollStateFromInterface( $token ); // Then $this->assertEquals( 'my-state', $newState ); } 

Kami juga perlu menambahkan tes untuk otentikasi, dll. Struktur ini sangat bagus sehingga Behat digunakan secara default .

4. Selalu menerapkan dependensi


Alasan utama : nada yang sangat buruk - untuk membuat rintisan bagi negara global. Ketidakmampuan untuk membuat stubs untuk dependensi tidak memungkinkan pengujian fungsi.

Petunjuk Bermanfaat: Lupakan kelas statis stateful dan instance tunggal . Jika kelas Anda bergantung pada sesuatu, maka buatlah agar itu dapat diimplementasikan.

Ini adalah contoh yang menyedihkan:

 class FeatureToggle { public function isActive( Id $feature ): bool { $cookieName = $feature->getCookieName(); // Early return if cookie // override is present if (Cookies::exists( $cookieName )) { return Cookies::get( $cookieName ); } // Evaluate feature toggle... } } 

Bagaimana saya bisa menguji jawaban awal ini?

Benar juga. Tidak mungkin.

Untuk mengujinya, kita perlu memahami perilaku kelas Cookies dan memastikan bahwa kita dapat mereproduksi semua lingkungan yang terkait dengannya, menghasilkan jawaban tertentu.

Jangan lakukan ini.

Situasi dapat diperbaiki jika Anda menerapkan instance dari Cookies sebagai ketergantungan. Tes akan terlihat seperti ini:

 // Test class... private Cookies $cookieMock; private FeatureToggle $service; // Preparing our service and dependencies public function setUp(): void { $this->cookieMock = $this->prophesize( Cookies::class ); $this->service = new FeatureToggle( $this->cookieMock->reveal() ); } public function testIsActiveIsOverriddenByCookies(): void { // Given $feature = $this->givenFeatureXExists(); // When $this->whenCookieOverridesFeatureWithTrue( $feature ); // Then $this->assertTrue( $this->service->isActive($feature) ); // additionally we can assert // no other methods were called } private function givenFeatureXExists(): Id { // ... return $feature; } private function whenCookieOverridesFeatureWithTrue( Id $feature ): void { $cookieName = $feature->getCookieName(); $this->cookieMock->exists($cookieName) ->shouldBeCalledOnce() ->willReturn(true); $this->cookieMock->get($cookieName) ->shouldBeCalledOnce() ->willReturn(true); } 

Hal yang sama berlaku untuk lajang. Jadi, jika Anda ingin membuat objek menjadi unik, konfigurasikan dengan benar injector dependensi Anda, daripada menggunakan pola (anti) tunggal. Jika tidak, Anda akan menulis metode yang hanya berguna untuk kasus seperti reset() atau setInstance() . Menurut saya, ini gila.

Sangat normal untuk mengubah arsitektur untuk membuat pengujian lebih mudah! Dan menciptakan metode untuk memfasilitasi pengujian tidak normal.

5. Jangan Pernah Menguji Metode yang Dilindungi / Privat


Alasan utama : mereka mempengaruhi cara kita menguji fungsi dengan menentukan tanda tangan perilaku: dalam kondisi seperti itu, ketika saya memasuki A, saya berharap untuk mendapatkan B. Metode pribadi / dilindungi bukan bagian dari tanda tangan fungsi .

Saya bahkan tidak ingin menunjukkan cara untuk "menguji" metode pribadi, tetapi saya akan memberikan petunjuk: Anda hanya dapat melakukan ini menggunakan API refleksi .

Selalu menghukum diri Anda entah bagaimana ketika Anda berpikir tentang menggunakan refleksi untuk menguji metode pribadi! Pengembang buruk, buruk!

Menurut definisi, metode pribadi hanya disebut secara internal. Artinya, mereka tidak tersedia untuk umum. Ini berarti bahwa hanya metode publik dari kelas yang sama yang dapat memanggil metode tersebut.

Jika Anda menguji semua metode publik Anda, maka Anda juga menguji semua metode pribadi / dilindungi . Jika ini bukan masalahnya, maka bebaskan metode privat / terlindungi, toh tidak ada yang menggunakannya.

Kiat lanjutan


Saya harap kamu belum bosan. Tetap saja, saya harus berbicara tentang dasar-dasarnya. Sekarang saya akan membagikan pendapat saya tentang penulisan tes bersih dan keputusan yang memengaruhi proses pengembangan saya.

Hal terpenting yang tidak saya lupakan saat menulis tes:

  • Belajar.
  • Umpan balik cepat.
  • Dokumentasi
  • Refactoring
  • Desain selama pengujian.

1. Tes di awal, bukan di akhir


Nilai : belajar, umpan balik cepat, dokumentasi, refactoring, desain selama pengujian.

Ini adalah dasar dari segalanya. Aspek yang paling penting, yang mencakup semua nilai yang tercantum. Saat Anda menulis tes sebelumnya, ini membantu Anda terlebih dahulu memahami bagaimana skema "diberikan, kapan, kemudian" harus disusun. Dengan melakukan itu, Anda pertama kali mendokumentasikan, dan, yang lebih penting, mengingat dan menetapkan persyaratan Anda sebagai aspek yang paling penting.

Apakah aneh mendengar tentang tes menulis sebelum implementasi? Dan bayangkan betapa anehnya menerapkan sesuatu, dan ketika menguji untuk mengetahuinya, semua ekspresi Anda "diberikan kapan, kemudian" tidak masuk akal.

Juga, pendekatan ini akan memeriksa harapan Anda setiap dua detik. Anda mendapatkan umpan balik secepat mungkin. Tidak peduli seberapa besar atau kecil tampilannya.

Tes hijau adalah area yang ideal untuk refactoring. Gagasan utama: tidak ada tes - tidak ada refactoring. Refactoring tanpa tes sangat berbahaya.

Akhirnya, mengatur struktur "diberikan ketika, lalu", akan menjadi jelas bagi Anda apa antarmuka yang harus dimiliki metode Anda dan bagaimana mereka harus bersikap. Menjaga tes tetap bersih juga akan memaksa Anda untuk terus membuat keputusan arsitektur yang berbeda. Ini akan memaksa Anda untuk membuat pabrik, antarmuka, mengganggu warisan, dll. Dan ya, pengujian akan menjadi lebih mudah!

Jika tes Anda adalah dokumen langsung yang menjelaskan cara kerja aplikasi, sangat penting untuk membuatnya jelas.

2. Lebih baik tanpa tes daripada dengan tes buruk


Nilai : studi, dokumentasi, refactoring.

Banyak pengembang memikirkan tes dengan cara ini: Saya akan menulis fitur, saya akan mengarahkan kerangka pengujian sampai tes mencakup sejumlah baris baru, dan mengirimkannya ke dalam operasi.

Menurut saya, Anda perlu lebih memperhatikan situasi ketika pengembang baru mulai bekerja dengan fitur ini. Apa yang akan tes katakan kepada orang ini?

Tes sering membingungkan jika nama tidak cukup detail. Apa yang lebih jelas: testCanFly atau testCanFlyReturnsFalseWhenPersonHasNoWings ?

Jika tes Anda hanya kode berantakan yang membuat kerangka mencakup lebih banyak garis, dengan contoh yang tidak masuk akal, maka sekarang saatnya untuk berhenti dan memikirkan apakah akan menulis tes ini sama sekali.

Bahkan omong kosong seperti menetapkan $a dan $b variabel, atau menetapkan nama yang tidak terkait dengan penggunaan tertentu.

Ingat : tes Anda adalah dokumen langsung yang mencoba menjelaskan bagaimana aplikasi Anda harus bersikap. assertFalse($a->canFly()) banyak mendokumentasikan. Dan assertFalse($personWithNoWings->canFly()) sudah cukup banyak.

3. Jalankan tes secara intrusi


Nilai : belajar, umpan balik cepat, refactoring.

Sebelum Anda mulai mengerjakan fitur, jalankan tes. Jika gagal sebelum Anda mulai berbisnis, Anda akan mengetahuinya sebelum Anda menulis kode, dan Anda tidak perlu menghabiskan menit-menit berharga men-debug tes yang rusak yang bahkan tidak Anda pedulikan.

Setelah menyimpan file, jalankan tes. Semakin cepat Anda menemukan bahwa ada sesuatu yang rusak, semakin cepat Anda memperbaikinya dan melanjutkan. Jika gangguan alur kerja untuk menyelesaikan masalah tampaknya tidak produktif bagi Anda, bayangkan bahwa nanti Anda harus kembali banyak langkah jika Anda tidak tahu tentang masalah tersebut.

Setelah mengobrol dengan rekan kerja selama lima menit atau memeriksa notifikasi dari Github, jalankan tes. Jika memerah, maka Anda tahu di mana Anda tinggalkan. Jika tes berwarna hijau, Anda dapat terus bekerja.
Setelah refactoring, bahkan nama variabel, jalankan tes.

Serius, jalankan tes sialan itu. Sesering Anda menekan tombol simpan.
Pengamat PHPUnit dapat melakukan ini untuk Anda, dan bahkan mengirim pemberitahuan!

4. Tes besar - tanggung jawab besar


Nilai : belajar, refactoring, desain selama pengujian.

Idealnya, setiap kelas harus memiliki satu tes. Tes ini harus mencakup semua metode publik di kelas ini, serta setiap ekspresi kondisional atau operator transisi ...

Anda dapat mengambil sesuatu seperti ini:

  • Satu kelas = satu test case.
  • Satu metode = satu atau lebih tes.
  • Satu cabang alternatif (jika / switch / coba-tangkap / pengecualian) = satu tes.

Jadi untuk kode sederhana ini Anda perlu empat tes:

 // class Person public function eatSlice(Pizza $pizza): void { // test exception if ([] === $pizza->slices()) { throw new LogicException('...'); } // test exception if (true === $this->isFull()) { throw new LogicException('...'); } // test default path (slices = 1) $slices = 1; // test alternative path (slices = 2) if (true === $this->isVeryHungry()) { $slices = 2; } $pizza->removeSlices($slices); } 

Semakin banyak metode publik yang Anda miliki, semakin banyak tes yang dibutuhkan.

Tidak ada yang suka membaca dokumentasi panjang. Karena tes Anda juga dokumen, ukuran kecil dan keberartiannya hanya akan meningkatkan kualitas dan kegunaannya.

Ini juga merupakan sinyal penting bahwa kelas Anda mengumpulkan tanggung jawab dan sekarang saatnya untuk memperbaikinya dengan mentransfer sejumlah fungsi ke kelas lain, atau mendesain ulang sistem.

5. Mendukung serangkaian tes untuk menyelesaikan masalah regresi


Nilai : belajar, dokumentasi, umpan balik cepat.

Pertimbangkan fungsinya:

 function findById(string $id): object { return fromDb((int) $id); } 

Anda berpikir bahwa seseorang mentransmisikan "10", tetapi sebenarnya "10 pisang" sedang dikirim. Yaitu, dua nilai datang, tetapi satu berlebihan. Anda memiliki bug.

Apa yang akan kamu lakukan pertama kali? Tulis tes yang akan menandai perilaku seperti itu salah !!!

 public function testFindByIdAcceptsOnlyNumericIds(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( 'Only numeric IDs are allowed.' ); findById("10 bananas"); } 

Tentu saja, tes tidak mengirimkan apa pun. Tapi sekarang Anda tahu apa yang perlu dilakukan agar mereka mengirimkan. Perbaiki kesalahan, lakukan pengujian hijau, gunakan aplikasi dan berbahagia.

Simpan tes ini bersamamu. Kapan saja memungkinkan, dalam serangkaian tes yang dirancang untuk menyelesaikan masalah dengan regresi.

Itu saja! Umpan balik cepat, perbaikan bug, dokumentasi, kode tahan regresi dan kebahagiaan.

Kata terakhir


Banyak hal di atas hanyalah pendapat pribadi saya, yang dikembangkan selama karier saya. Ini tidak berarti bahwa nasihat itu benar atau salah, itu hanya pendapat.

Source: https://habr.com/ru/post/id485124/


All Articles