Unittests für Methoden einer Klasse

Wenn du dir nicht sicher bist, in welchem der anderen Foren du die Frage stellen sollst, dann bist du hier im Forum für allgemeine Fragen sicher richtig.
Atalanttore
User
Beiträge: 407
Registriert: Freitag 6. August 2010, 17:03

@__blackjack__: Okay. Dann weiß ich wieder etwas mehr.


Ich habe nun die Klasse `App` um einen Parameter erweitert, der bei der Instanziierung eines Objektes übergeben werden muss. In der Testdatei (nennt man das so?) habe ich für diesen Parameter einen Wert global definiert. Ist das ein guter Stil?

main.py:

Code: Alles auswählen

class App:
    def __init__(self, exponent):
        self.factor = exponent
        for i in range(0, 10):
            print(self._multi(i, exponent))

    def _multi(self, number, factor):
        return number ** factor


if __name__ == "__main__":
    app = App(10)
test_main.py:

Code: Alles auswählen

from pytest import fixture

import main

exponent = 10

@fixture
def app():
    return main.App(exponent)


def test_app_multi(app):
    assert app._multi(5, exponent) == 9765625


def test_app_init(capsys):
    app = main.App(exponent)
    captured = capsys.readouterr()
    assert captured.out == '''\
0
1
1024
59049
1048576
9765625
60466176
282475249
1073741824
3486784401
'''

Gruß
Atalanttore
Benutzeravatar
__blackjack__
User
Beiträge: 13068
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@Atalanttore: Auch in Modulen mit Unittests sollte man sich an die üblichen Konventionen halten und Konstanten komplett gross schreiben. Die Module selbst würde ich mal sagen haben keinen speziellen Namen, denn Unittests müssen ja auch nicht zwingend in extra Modulen stecken. Es gibt auch Leute die sie gleich in die Module mit dem Code hinein schreiben. Mache ich zum Beispiel meistens wenn das kleine Werkzeug sowieso nur aus einem Modul, also eigentlich einer Skriptdatei besteht, und nicht schon zu lang ist.

Ob `EXPONENT` nun semantisch tatsächlich eine (sinnvolle) Konstante ist, kann man schlecht sagen, weil dieses Beispiel an sich ja gar keinen Sinn hat, ausser halt ein Beispiel zu sein. Ist das denn nur für die Tests ein besonderer, konstanter Wert?
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Atalanttore
User
Beiträge: 407
Registriert: Freitag 6. August 2010, 17:03

@__blackjack__: Wie lang ist "nicht schon zu lang"? Ist eine Skriptdatei mit 500 Codezeilen schon zu lang?

`EXPONENT` bzw. `exponent` ist nur ein Beispielwert, weil man beim Instanziieren eines Objektes der Klasse `App` ja einen Wert dafür angeben muss.

Gruß
Atalanttore
Atalanttore
User
Beiträge: 407
Registriert: Freitag 6. August 2010, 17:03

Momentan komme ich bei der Ergründung der Ursache einer Fehlermeldung bei einem pytest nicht voran. Auf Stack Overflow schrieb jemand zu der Fehlermeldung, dass der Speicher ausgeht, aber der Arbeitsspeicher meines Rechners (8 GB) ist nur bis zur Hälfte belegt.

Fehlermeldung:

Code: Alles auswählen

Testing started at 18:35 ...
/usr/bin/python3.6 /snap/pycharm-community/112/helpers/pycharm/_jb_pytest_runner.py --path /home/ata/source/test_bmi.py
Launching pytest with arguments /home/ata/source/test_bmi.py in /home/ata/source

============================= test session starts ==============================
platform linux -- Python 3.6.7, pytest-4.2.1, py-1.7.0, pluggy-0.8.1
rootdir: /home/ata/source, inifile:collected 1 item

test_bmi.py 
Process finished with exit code 134 (interrupted by signal 6: SIGABRT)

test_bmi.py:

Code: Alles auswählen

from pytest import fixture

import main


@fixture
def main_window():
    return main.MainWindow()


def test_bmi_main_window(main_window):
    assert main_window._calculate_BMI(180, 80) == 24.691358024691358

bmi.py:

Code: Alles auswählen

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys
from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt5.uic import loadUi


class MainWindow(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)
        loadUi("bmi.ui", self)

        self._write_size()
        self._write_weight()
        self._write_BMI()

        self.horizontalSlider_size.sliderMoved.connect(self._write_BMI)
        self.horizontalSlider_size.sliderMoved.connect(self._write_size)

        self.horizontalSlider_weight.sliderMoved.connect(self._write_BMI)
        self.horizontalSlider_weight.sliderMoved.connect(self._write_weight)

    @property
    def _size(self):
        return self.horizontalSlider_size.value()

    @property
    def _weight(self):
        return self.horizontalSlider_weight.value()

    def _write_size(self):
        self.output_size.setText(f"{self._size} cm")

    def _write_weight(self):
        self.output_weight.setText(f"{self._weight} kg")

    def _calculate_BMI(self, size, weight):
        return weight / (size / 100) ** 2

    def _write_BMI(self):
        BMI = self._calculate_BMI(self._size, self._weight)
        self.output_BMI.setText(str(round(BMI, 2)))


def main():
    app = QApplication(sys.argv)
    main_window = MainWindow()
    main_window.show()
    sys.exit(app.exec_())


if __name__ == '__main__':
    main()
bmi.ui:

Code: Alles auswählen

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>MainWindow</class>
 <widget class="QMainWindow" name="MainWindow">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>800</width>
    <height>600</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>Berechnungen</string>
  </property>
  <widget class="QWidget" name="centralwidget">
   <widget class="QTabWidget" name="tabWidget">
    <property name="geometry">
     <rect>
      <x>50</x>
      <y>40</y>
      <width>681</width>
      <height>451</height>
     </rect>
    </property>
    <property name="currentIndex">
     <number>0</number>
    </property>
    <widget class="QWidget" name="tab_2">
     <attribute name="title">
      <string>BMI</string>
     </attribute>
     <widget class="QFrame" name="frame">
      <property name="geometry">
       <rect>
        <x>50</x>
        <y>50</y>
        <width>591</width>
        <height>151</height>
       </rect>
      </property>
      <property name="frameShape">
       <enum>QFrame::StyledPanel</enum>
      </property>
      <property name="frameShadow">
       <enum>QFrame::Raised</enum>
      </property>
      <layout class="QGridLayout" name="gridLayout">
       <item row="0" column="0">
        <widget class="QLabel" name="label_size">
         <property name="text">
          <string>Größe:</string>
         </property>
        </widget>
       </item>
       <item row="0" column="1" colspan="3">
        <widget class="QSlider" name="horizontalSlider_size">
         <property name="toolTipDuration">
          <number>-1</number>
         </property>
         <property name="minimum">
          <number>100</number>
         </property>
         <property name="maximum">
          <number>250</number>
         </property>
         <property name="sliderPosition">
          <number>175</number>
         </property>
         <property name="orientation">
          <enum>Qt::Horizontal</enum>
         </property>
         <property name="tickPosition">
          <enum>QSlider::NoTicks</enum>
         </property>
        </widget>
       </item>
       <item row="0" column="4">
        <widget class="QLabel" name="output_size">
         <property name="text">
          <string>x cm</string>
         </property>
        </widget>
       </item>
       <item row="1" column="0" colspan="2">
        <widget class="QLabel" name="label_weight">
         <property name="text">
          <string>Gewicht:</string>
         </property>
        </widget>
       </item>
       <item row="1" column="2">
        <widget class="QSlider" name="horizontalSlider_weight">
         <property name="minimum">
          <number>1</number>
         </property>
         <property name="maximum">
          <number>300</number>
         </property>
         <property name="singleStep">
          <number>1</number>
         </property>
         <property name="sliderPosition">
          <number>80</number>
         </property>
         <property name="orientation">
          <enum>Qt::Horizontal</enum>
         </property>
         <property name="tickPosition">
          <enum>QSlider::NoTicks</enum>
         </property>
        </widget>
       </item>
       <item row="1" column="3" colspan="2">
        <widget class="QLabel" name="output_weight">
         <property name="text">
          <string>x kg</string>
         </property>
        </widget>
       </item>
      </layout>
     </widget>
     <widget class="QWidget" name="layoutWidget">
      <property name="geometry">
       <rect>
        <x>257</x>
        <y>220</y>
        <width>191</width>
        <height>41</height>
       </rect>
      </property>
      <layout class="QHBoxLayout" name="horizontalLayout_2">
       <item>
        <widget class="QLabel" name="label_BMI">
         <property name="text">
          <string>BMI:</string>
         </property>
        </widget>
       </item>
       <item>
        <widget class="QLabel" name="output_BMI">
         <property name="text">
          <string/>
         </property>
        </widget>
       </item>
      </layout>
     </widget>
    </widget>
   </widget>
  </widget>
  <widget class="QStatusBar" name="statusbar"/>
 </widget>
 <resources/>
 <connections/>
</ui>
Gruß
Atalanttore
__deets__
User
Beiträge: 14522
Registriert: Mittwoch 14. Oktober 2015, 14:29

Und du benutzt auch die 64 Bit Version von Python, damit der Adressraum über die mit 32 Bit verfügbaren 4GB hinaus geht....?
Sirius3
User
Beiträge: 17737
Registriert: Sonntag 21. Oktober 2012, 17:20

@__deets__: ein kleines QT-Programm wird wohl kaum 4GB brauchen.

@Atalanttore: nicht alles was auf Stackoverflow steht, passt immer und erst recht nicht auf jedes Problem. Du willst ein GUI-Programm mit pytest testen? Schwierig.
Dein Fehler hier ist aber, dass Du ein QMainWindow erzeugst, ohne dass Du eine QApplication hast.
Zum Test: _calculate_BMI hat gar keine Abhängigkeit vom Fenster, sollte also gar keine Methode sein.
Atalanttore
User
Beiträge: 407
Registriert: Freitag 6. August 2010, 17:03

@Sirius3:
  1. Wie testet man ein GUI-Programm am besten?
  2. Wie erstellt man eine `QApplication` in einem `pytest`?
`_calculate_BMI()` habe ich lediglich erstellt, damit ich etwas für `pytest` zum Testen habe.

Gruß
Atalanttore
__deets__
User
Beiträge: 14522
Registriert: Mittwoch 14. Oktober 2015, 14:29

@Sirius3: auf den Code habe ich nicht mal geschaut 😳

@Atalanttore: leider ist Qt nicht so dolle zum Unit testen. Ärgert mich persönlich. Du kannst natürlich einfach eine Qapplication in deiner fixture machen. Es hört dann aber schon auf, wenn man künstliche User-Ereignisse erzeugen will. Was geht, und was ich auch mache: durch Trennung von GUI und Anwendungslogik (eh eine gute Idee) teste ich dann eben nur die letztere. Zb mit der Klasse Qsignalspy. Wobei die für PyQt wohl unnötig ist, da kann man ja auch einfach schnell ein Test-QObject machen, und das mit Signalen und Slots ausstatten.
Atalanttore
User
Beiträge: 407
Registriert: Freitag 6. August 2010, 17:03

@__deets__: Ist die Trennung von GUI und Anwendungslogik auch eine gute Idee, wenn die Klasse mit der Anwendungslogik nur aus einer Methode und ein paar Attributen besteht?

Gruß
Atalanttore
__deets__
User
Beiträge: 14522
Registriert: Mittwoch 14. Oktober 2015, 14:29

Es ist fast immer eine gute Idee. Das Risiko schlecht wartbaren Code zu schreiben weil man sich die Mühe nicht macht, überwiegt das Risiko zu kompliziert und zeitaufwändig zu arbeiten, bei weitem. Gerade bei GUIs läuft man schnell die Gefahr, Domänen-Objekte mit GUI-Objekten gleich zu setzen. Und wehe man muss dieses verklebte Wollknäuel dann entwirren...

Qt selbst und andere Toolkits versuchen das zu unterstützen durch Muster wie MVC die es dir erlauben, Daten/Modellklassen von deren Verflechtung mit der Widgethierarchie zu lösen.
Atalanttore
User
Beiträge: 407
Registriert: Freitag 6. August 2010, 17:03

@__deets__: Ich habe wieder ein paar Nachfragen.
  1. In welchem Ausnahmefall ist die Erstellung mehrerer kleiner Klassen (mit jeweils nur einer Methode und ein paar Attributen) keine gute Idee?
  2. Meinst du mit Domänen-Objekte die Anwendungslogik?
__deets__ hat geschrieben: Sonntag 17. Februar 2019, 23:23 @Atalanttore: leider ist Qt nicht so dolle zum Unit testen. Ärgert mich persönlich. Du kannst natürlich einfach eine Qapplication in deiner fixture machen.
test_bmi.py habe ich entsprechend geändert, aber das führt jetzt zu einer langen Fehlermeldung.

test_bmi.py:

Code: Alles auswählen

from pytest import fixture
from PyQt5.QtWidgets import QApplication

import main
import sys

@fixture
def main_window():
    app = QApplication(sys.argv)
    return main.MainWindow(app)


def test_bmi_main_window(main_window):
    assert main_window._calculate_BMI(180, 80) == 24.691358024691358

Fehlermeldung:

Code: Alles auswählen

Testing started at 16:04 ...
/usr/bin/python3.6 /snap/pycharm-community/112/helpers/pycharm/_jb_pytest_runner.py --path /home/ata/source/test_bmi.py
Launching pytest with arguments /home/ata/source/test_bmi.py in /home/ata/source

============================= test session starts ==============================
platform linux -- Python 3.6.7, pytest-4.2.1, py-1.7.0, pluggy-0.8.1
rootdir: /home/ata/source, inifile:collected 1 item

test_bmi.py E
test setup failed
@fixture
    def main_window():
        app = QApplication(sys.argv)
>       return main.MainWindow(app)

test_bmi.py:10: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <main.MainWindow object at 0x7f8b3028a9d8>
parent = <PyQt5.QtWidgets.QApplication object at 0x7f8b3028a678>

    def __init__(self, parent=None):
>       super().__init__(parent)
E       TypeError: QMainWindow(parent: QWidget = None, flags: Union[Qt.WindowFlags, Qt.WindowType] = Qt.WindowFlags()): argument 1 has unexpected type 'QApplication'

main.py:12: TypeError
                                                            [100%]

==================================== ERRORS ====================================
____________________ ERROR at setup of test_bmi_main_window ____________________

    @fixture
    def main_window():
        app = QApplication(sys.argv)
>       return main.MainWindow(app)

test_bmi.py:10: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <main.MainWindow object at 0x7f8b3028a9d8>
parent = <PyQt5.QtWidgets.QApplication object at 0x7f8b3028a678>

    def __init__(self, parent=None):
>       super().__init__(parent)
E       TypeError: QMainWindow(parent: QWidget = None, flags: Union[Qt.WindowFlags, Qt.WindowType] = Qt.WindowFlags()): argument 1 has unexpected type 'QApplication'

main.py:12: TypeError
=========================== 1 error in 0.34 seconds ============================
Process finished with exit code 0

Gruß
Atalanttore
__deets__
User
Beiträge: 14522
Registriert: Mittwoch 14. Oktober 2015, 14:29

Wie kommst du denn nun auf das schmale Brett, dass du ploetzlich die QApplication als *Argument* an ein QWindow uebergibst. Du hast doch funktionierende Qt-Anwendungen. Wird das da auch gemacht? Ich habe das auch nirgendwo erwaehnt, das du das machen sollst. Du musst eine Instanz der QApplication erstellen. So wie in JEDEM Qt-Programm. Die wiederum ueberall in irgendwelche Konstruktoren reinzuwerfen... 🤷‍♂️

Und ja, Domaenen-Objekte sind die Objekte, die deine Anwendung konzeptionell darstellen. Adressen in einer Adressverwaltung zB. Oder eine FUNKTION (weil mehr brauchst du nicht), die einen BMI ausrechnet.

Last but not least zu Frage 1: ich habe nichts zur Erstellung von kleinen Klassen gesagt. Ich habe etwas zur Vermengung von Anwendungslogik und Darstellungslogik gesagt.
__deets__
User
Beiträge: 14522
Registriert: Mittwoch 14. Oktober 2015, 14:29

Um mal ein konkretes Beispiel zu nenne: dein Mainwindow oben mit _calculate_BMI ist nicht so gelungen, denn fuer diese Berechnung ist zum einen kein Objekt noetig, sondern nur die beiden Eingaben. Was du schon daran siehst, dass du noch nicht mal auf den self-Parameter zugreifst.

Du kannst jetzt also eine Funktion calc_bmi schreiben, und die testen. Und *verwenden* von deiner GUI-Klasse.
Benutzeravatar
__blackjack__
User
Beiträge: 13068
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

Und vor allem kann man diese Funktion dann auch komplett ohne GUI testen.
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Atalanttore
User
Beiträge: 407
Registriert: Freitag 6. August 2010, 17:03

__deets__ hat geschrieben: Montag 18. Februar 2019, 16:35 Wie kommst du denn nun auf das schmale Brett, dass du ploetzlich die QApplication als *Argument* an ein QWindow uebergibst. Du hast doch funktionierende Qt-Anwendungen. Wird das da auch gemacht? Ich habe das auch nirgendwo erwaehnt, das du das machen sollst. Du musst eine Instanz der QApplication erstellen. So wie in JEDEM Qt-Programm. Die wiederum ueberall in irgendwelche Konstruktoren reinzuwerfen... 🤷‍♂️
In manchen der Beispielprogramme wird `QApplication` als Argument übergeben. In anderen wieder nicht.

__deets__ hat geschrieben: Montag 18. Februar 2019, 16:35 Und ja, Domaenen-Objekte sind die Objekte, die deine Anwendung konzeptionell darstellen. Adressen in einer Adressverwaltung zB. Oder eine FUNKTION (weil mehr brauchst du nicht), die einen BMI ausrechnet.
__blackjack__ hat geschrieben: Montag 18. Februar 2019, 17:33 Und vor allem kann man diese Funktion dann auch komplett ohne GUI testen.
Den Aufwand, Qt zufrieden zu stellen, kann man sich mit einer Funktion schön sparen. Es kann so einfach sein mit einer Funktion.

__deets__ hat geschrieben: Montag 18. Februar 2019, 16:35 Last but not least zu Frage 1: ich habe nichts zur Erstellung von kleinen Klassen gesagt. Ich habe etwas zur Vermengung von Anwendungslogik und Darstellungslogik gesagt.
Weil du nichts zur Erstellung kleiner Klassen geschrieben hast, um Anwendungslogik und GUI immer zu trennen, habe ich explizit danach gefragt. :mrgreen:


In "test_bmi.py" habe ich noch einen weiteren Fehler entdeckt. Für den pytest wurde "main" importiert, obwohl die Datei "bmi.py" heißt. Ohne den für Qt notwendigen Code ist die Datei auch stark zusammengeschrumpft. Der Test funktioniert jetzt problemlos.

test_bmi.py

Code: Alles auswählen

import bmi


def test_bmi_main_window():
    assert bmi.calculate_bmi(180, 80) == 24.691358024691358

bmi.py

Code: Alles auswählen

import sys
from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt5.uic import loadUi


def calculate_bmi(size, weight):
    return weight / (size / 100) ** 2


class MainWindow(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)
        loadUi("bmi.ui", self)

        self._write_size()
        self._write_weight()
        self._write_bmi()

        self.horizontalSlider_size.sliderMoved.connect(self._write_bmi)
        self.horizontalSlider_size.sliderMoved.connect(self._write_size)

        self.horizontalSlider_weight.sliderMoved.connect(self._write_bmi)
        self.horizontalSlider_weight.sliderMoved.connect(self._write_weight)

    @property
    def _size(self):
        return self.horizontalSlider_size.value()

    @property
    def _weight(self):
        return self.horizontalSlider_weight.value()

    def _write_size(self):
        self.output_size.setText(f"{self._size} cm")

    def _write_weight(self):
        self.output_weight.setText(f"{self._weight} kg")

    def _write_bmi(self):
        BMI = calculate_bmi(self._size, self._weight)
        self.output_BMI.setText(str(round(BMI, 2)))


def main():
    app = QApplication(sys.argv)
    main_window = MainWindow()
    main_window.show()
    sys.exit(app.exec_())


if __name__ == '__main__':
    main()
Gruß
Atalanttore
__deets__
User
Beiträge: 14522
Registriert: Mittwoch 14. Oktober 2015, 14:29

Hast du mal ein Beispiel, wo die QApplication als erstes Argument and QMainWindow uebergeben wird? Das moechte ich sehen. Ob Leute von QMainWindow ABLEITEN, und sich dann eigene Konstruktoren bauen, an die man Hund, Katze, Maus uebergeben kann - ja gut. Aber das ist ja nicht was du gemacht hast.
Atalanttore
User
Beiträge: 407
Registriert: Freitag 6. August 2010, 17:03

@__deets__: Hier ist so ein Beispiel. Gerade nochmal getestet und das Beispiel tut was es soll.

PyCharm zeigt bei der Übergabe von 'QApplication' an ein 'QMainWindow' immerhin folgende Warnung an:
PyCharm hat geschrieben:Expected type 'Optional[QWidget]', got 'QApplication' instead
Inspection info: This inspection detects type errors in function call expressions. Due to dynamic dispatch and duck typing, this is possible in a limited but useful number of cases. Types of function parameters can be specified in docstrings or in Python 3 function annotations.
Gruß
Atalanttore
__deets__
User
Beiträge: 14522
Registriert: Mittwoch 14. Oktober 2015, 14:29

Und wenn du mal genau schaust was das MainWindow (eine eigene Klasse) mit dem übergebenen Argument macht? Wird das an den konstruktor von QMainWindow übergeben?
Atalanttore
User
Beiträge: 407
Registriert: Freitag 6. August 2010, 17:03

@__deets__: Der Konstruktor des `MainWindow` macht mit dem übergebenen Argument offensichtlich nichts. Das Programm funktioniert auch immer noch, wenn ich einen sinnlosen Textstring anstatt einem `QApplication` als Argument übergebe.

Mit zwei übergebenen Argumenten kommt es allerdings zu folgendem Fehler.

Code: Alles auswählen

main_window = MainWindow(app, sinnloser_text)
TypeError: __init__() takes from 1 to 2 positional arguments but 3 were given
Der TypeError scheint ein zusätzliches Argument zu kennen. Nach meinem Verständnis müsste die Fehlermeldung lauten, dass `__init__()` 0 bis 1 positionale Argumente übernimmt, aber 2 übergeben wurden.

Gruß
Atalanttore
Benutzeravatar
__blackjack__
User
Beiträge: 13068
Registriert: Samstag 2. Juni 2018, 10:21
Wohnort: 127.0.0.1
Kontaktdaten:

@Atalanttore: Du vergisst das implizit übergebene `self`. Damit sind das drei Argumente.
„All religions are the same: religion is basically guilt, with different holidays.” — Cathy Ladman
Antworten