Asosiy kontentga o‘tish

Maqolalar

Darslik 11 daqiqa o'rta 2 ko‘rish 0 yoqdi

Ishlaydigan koddan sifatli kodga

#python #testing

Mundarija

Oldingi qismda chek yasaydigan dastur yozdik. U bazadan ma'lumot oldi, hisob-kitob qildi va PDF fayl yaratdi. Papkani ochsangiz, tayyor chek turibdi.

Endi savolni biroz o'zgartiramiz. "Ishladimi?" degan savolga javob oldik. Endi esa muhimrog'i — "to'g'ri ishlaydimi?"

Bu ikkisi bir xil narsa emas. Dastur hech qanday xato bermasdan ishlashi, lekin baribir noto'g'ri natija chiqarishi mumkin. Hozir aynan shunday holatni ko'ramiz.

Xaridor kalkulyator olsa va hissoblasa#

Tasavvur qiling, xaridor chekni olib, qatorlardagi QQS summalarini kalkulyatorda qo'shib chiqdi. Keyin pastdagi shundan QQS qatoriga qaradi.

Ba'zi holatlarda bu ikki summa bir-biriga teng chiqmaydi.

Buni tekshirish uchun tasodifiy savatlar yasab ko'ramiz: har birida 3-6 ta mahsulot, narxlari tiyinlari bilan, QQS esa 0%, 7% yoki 12%. So'ng har bir savatda qatorlardagi QQS summalarini qo'shib, pastdagi jami QQS bilan solishtiramiz.

Natija
500 ta savatdan 174 tasida chekdagi QQS hisoblari bir-biriga mos kelmadi — bu taxminan **35%**.

Deyarli har uchta savatdan bittasida shunday muammo chiqdi. Mana bir misol:

Natija
3 x 42,438.57  QQS 7%
3 x 27,679.26  QQS 12%
2 x  8,285.10  QQS 7%
5 x 45,065.36  QQS 7%
4 x 26,626.46  QQS 12%
1 x 46,562.02  QQS 12%

qatorlarni qo'shsangiz : 54,177.56
chekdagi JAMI QQS      : 54,177.57

Farq atigi bir tiyin. Kichkina ko'rinadi, lekin chek — hisob hujjati. Undagi ikkita hisob bir-biriga mos kelmasa, farq qancha ekanidan qat'i nazar, natija noto'g'ri hisoblanadi.

Sabab#

Kodimizga qaytaylik. Chekdagi har bir qator chiqarilganda, summa ikki xonagacha yaxlitlanadi:

Python
can.drawRightString(ENI - CHET, y - 30, f"{qator_qqs:,.2f}")

Pastdagi jami QQS esa yaxlitlanmagan qiymatlarni qo‘shish orqali hisoblanadi:

Python
qqs = sum(q[5] for q in qatorlar)

Ya'ni ekranda 1,080.00 deb ko'ringan qiymat aslida 1,080.004 bo'lishi mumkin.

Har bir qatorda shunday kichik qoldiq qoladi. Ular yig'ilib borib, oxirida bir tiyin farq chiqaradi.

Muhim joyi: bu float xatosi emas. Decimal ga o'tsak ham natija o'zgarmaydi. Muammo son turida emas, yaxlitlash tartibida: qatorlar bir usulda, jami esa boshqa usulda yaxlitlanyapti.

Tuzatish#

Qoida sodda: pul qiymatini bir marta yaxlitlaymiz va keyingi hisoblarda aynan shu qiymatdan foydalanamiz. Jami QQS esa chekda ko‘rsatilgan qatorlardagi QQS summalarining yig‘indisi bo‘ladi — boshqa qiymatdan qayta hisoblanmaydi.

chek.py
from decimal import ROUND_HALF_UP, Decimal

TIYIN = Decimal("0.01")


def pul(qiymat) -> Decimal:
    """Pul qiymatini tiyingacha bir marta yaxlitlaymiz. Keyingi hisoblarda ham aynan shu qiymat ishlatiladi — shunda chekdagi qatorlar bilan jami bir-biriga mos keladi."""
    return Decimal(str(qiymat)).quantize(TIYIN, rounding=ROUND_HALF_UP)

Endi qatorlarni bazadan olayotganda, pul qiymatlarini darrov tiyingacha yaxlitlab olamiz:

chek.py
@dataclass(frozen=True)
class Qator:
    nom: str
    dona: int
    narx: Decimal
    jami: Decimal
    foiz: Decimal
    qqs: Decimal


def mahsulotlar(chek_raqami: int) -> list[Qator]:
    """Chekdagi har bir qator summasi shu yerning o'zida, faqat bir marta yaxlitlanadi."""
    with sqlite3.connect(BAZA) as con:
        xom = con.execute(
            "SELECT product, count, price, qqs FROM Purchase WHERE id = ?",
            (chek_raqami,),
        ).fetchall()

    qatorlar = []
    for nom, dona, narx, foiz in xom:
        narx_d, foiz_d = pul(narx), Decimal(str(foiz))
        jami = pul(narx_d * dona)
        qatorlar.append(Qator(nom, dona, narx_d, jami, foiz_d, pul(jami * foiz_d / 100)))
    return qatorlar

Va jami shu qatorlardan yig'iladi:

chek.py
jami = sum((q.jami for q in qatorlar), Decimal("0"))
qqs = sum((q.qqs for q in qatorlar), Decimal("0"))

Decimal(str(narx)) yozilishiga e'tibor bering — Decimal(narx) emas. float qiymatni to'g'ridan-to'g'ri bersak, undagi taxminiy qiymat Decimal ga ham o'tadi. str() orqali o'tkazsak esa ko'rib turgan sonimiz saqlanadi.

Bu safar Qator oddiy tuple emas, dataclass bo'ladi. Sababi oddiy: oldin q[5] deb yozib, oltinchi element nimani anglatishini eslab yurish kerak edi. Endi esa q.qqs deb yozamiz — o'qish ham, tushunish ham osonroq.

Papka bo'lmasa dastur yiqiladi#

qr/ papkasini o'chirib, dasturni qayta ishga tushirib ko'ramiz:

Natija
FileNotFoundError: [Errno 2] No such file or directory: 'qr/chek1.png'

Muammo oddiy: dastur qr/ papkasi oldindan mavjud bo'lishini kutyapti. Aslida esa foydalanuvchi uni qo'lda yaratib yurishi shart emas — dastur kerakli papkani o'zi yaratishi mumkin:

chek.py
from pathlib import Path

CHEKLAR = Path("cheklar")
QRLAR = Path("qr")

for papka in (CHEKLAR, QRLAR):
    papka.mkdir(parents=True, exist_ok=True)

exist_ok=True — papka oldindan mavjud bo'lsa, xato bermaydi. parents=True esa kerak bo'lsa, yo'ldagi oraliq papkalarni ham birga yaratadi.

Bazaga ulanish yopilmaydi#

Oldingi kodda shunday edi:

Python
con = sqlite3.connect("db/savdo.db")
return con.execute(...).fetchall()

Ulanish ochiladi, lekin yopilmaydi. Bitta chek yaratishda bu deyarli sezilmaydi. Ammo dastur uzoq vaqt ishlasa yoki minglab chek yaratsa, ochiq ulanishlar yig'ilib borib muammo tug'dirishi mumkin.

Yechim — with:

chek.py
with sqlite3.connect(BAZA) as con:
    xom = con.execute(...).fetchall()

Logotip o'lchami kodda qotib qolgan#

Bu qator bir qarashda oddiy va zararsiz ko‘rinadi:

Python
logo_h = 200 * (120 / 600)  # logo 120pt kenglikda

Bu yerda 600 va 200 — logotip faylining haqiqiy o‘lchami. Demak, kod aynan shu logotipga moslab yozilgan. Boshqa o‘lchamdagi logo qo‘ysak, u cho‘zilib yoki siqilib chiqishi, chek balandligi esa noto‘g‘ri hisoblanishi mumkin.

Yomon tomoni — dastur xato bermaydi. PDF yaratiladi, faqat logo noto‘g‘ri ko‘rinadi.

Shuning uchun o‘lchamni kodga yozib qo‘yish o‘rniga, faylning o‘zidan olamiz:

chek.py
def rasm_balandligi(yol, kenglik) -> float:
    w, h = ImageReader(str(yol)).getSize()
    return h * (kenglik / w)


logo_h = rasm_balandligi(LOGO, 120)

Nom belgi soniga qarab kesilyapti#

Oldingi qismda ajratuvchi chiziq uzunligini taxmin qilmasdan, o'lchash kerakligini ko'rdik. Buning uchun stringWidth ishlatgan edik. Lekin mahsulot nomini hali ham shunday kesyapmiz:

Python
can.drawString(CHET, y, nom[:28])

Bu yerda 28 — shunchaki taxmin. Masalan, WWWWWWWW va iiiiiiii ikkalasi ham 8 ta belgidan iborat, lekin ekrandagi kengligi bir xil emas.

Chiziqni o'lchagan bo'lsak, mahsulot nomini ham o'lchab qisqartirishimiz kerak:

chek.py
def qisqartir(can, matn: str, kenglik: float) -> str:
    """Nomni belgi soniga qarab emas, o'lchab qisqartiradi."""
    if can.stringWidth(matn) <= kenglik:
        return matn

    while matn and can.stringWidth(matn + "…") > kenglik:
        matn = matn[:-1]

    return matn + "…"

Endi uzun nom chek eniga qarab qisqaradi va oxiriga qo'yiladi. Shu orqali o'quvchi mahsulot nomi davom etishini tushunadi.

Versiyalar qulflanmagan#

Bu kamchilik boshqalaridan biroz farq qiladi: muammo bugun emas, oradan vaqt o'tgach chiqishi mumkin.

Eski loyihalarni qayta ishga tushirganda bunday holat tez-tez uchraydi: loyiha yozilgan paytdagi kutubxona versiyalari yangi Python bilan ishlamay qoladi yoki yangi kutubxona versiyasida eski kodga ta'sir qiladigan o'zgarish paydo bo'ladi.

Natijada kecha ishlagan kodga tegmagan bo'lsangiz ham, yangi muhitda u ishlamay qolishi mumkin.

Shuning uchun requirements.txt da ishlatgan versiyalarimizni aniq yozib qo'yamiz:

INI
reportlab==5.0.1
segno==1.6.6

Bu loyiha kelajakda hech qachon buzilmaydi degani emas. Lekin keyin uni ochgan odam qaysi kutubxona versiyalari bilan ishlaganini aniq biladi. Muammoni topishda buning o'zi katta yordam beradi.

Endi test yozamiz#

Yuqoridagi tuzatishlarning eng muhimi — QQS hisobini bir joyda yaxlitlash edi. Lekin uning haqiqatan tuzalganini qanday bilamiz?

Test yozamiz. Test mantig'i xaridorniki bilan bir xil:

chekda ko'rinadigan qatorlarni qo'shamiz va pastdagi jami bilan solishtiramiz.

Python
def matndan(son_matni: str) -> Decimal:
    """Chekdagi '54,177.56' ni yana songa aylantiradi."""
    return Decimal(son_matni.replace(",", ""))


def test_chop_etilgan_qatorlar_yigindisi_jamiga_teng(tmp_path, monkeypatch):
    baza = tmp_path / "sinov.db"
    monkeypatch.setattr(chek2, "BAZA", baza)

    rng = random.Random(7)

    for _ in range(500):
        qatorga_yoz(baza, tasodifiy_savat(rng))
        qatorlar = chek2.mahsulotlar(1)

        # chekda ko'rinadigan qiymatlar
        qator_matnlari = [f"{q.qqs:,.2f}" for q in qatorlar]
        jami_matni = f"{sum((q.qqs for q in qatorlar), Decimal('0')):,.2f}"

        assert sum(matndan(m) for m in qator_matnlari) == matndan(jami_matni)

Bu yerda muhim joyi shuki, test faqat dastur ichidagi sonlarni emas, chekda ko'rinadigan qiymatlarni tekshiryapti.

Chunki xaridor ham ichkaridagi Decimal qiymatini ko'rmaydi. Unga qog'ozda nima yozilgan bo'lsa, o'shani qo'shadi.

Ishga tushiramiz:

Terminal
pytest -q test_chek.py
Text only
.                                                                        [100%]

1 passed in 0.25s

500 ta savatni tekshirdik — birortasida ham qatorlar bilan jami bir-biriga zid chiqmadi.

Xuddi shu tekshiruvni eski hisoblash usuli bilan bajarsak, 500 ta savatdan 174 tasida farq chiqardi.

Demak, testimiz shunchaki yashil yonib turadigan test emas — eski xatoni ushlay oladi.

Yana bitta muhim odat#

Eski kodlarda SQL so'rovining bunday yozilganini ko'p uchratish mumkin:

Python
sqlcode = f"""SELECT product, count, price FROM Purchase WHERE id={Check.count};"""

Hozir bu qiymat dastur ichidan kelayotgan bo'lishi mumkin. Lekin qiymatni SQL matniga shu tarzda qo'shishning o'zi yomon odat: ertaga shu joyga foydalanuvchi kiritgan ma'lumot kelib qolsa, SQL injection xavfi paydo bo'ladi.

Biz esa birinchi qismdanoq parametr bilan yozdik:

Python
con.execute("... WHERE id = ?", (chek_raqami,))

Bu usulda qiymat SQL so'rovining bir qismi bo'lib ketmaydi, alohida parametr sifatida uzatiladi.

Nega bu muhimligini amalda ko'rish uchun SQL injection'ni o'z ko'zingiz bilan ko'ring maqolasiga qarashingiz mumkin.

Shu qismning asosiy narsalari#

Ishlayotgan kod har doim ham to'g'ri kod degani emas. Bizning dastur xato bermay ishlardi, lekin ayrim cheklarda qatorlar bilan jami bir-biriga mos kelmasdi.

Pulni bir marta yaxlitlang. Chekda ko'rsatiladigan qiymat bilan keyingi hisoblarda ishlatiladigan qiymat bir xil bo'lsin.

Taxmin qilmang — o'lchang yoki ma'lumotning o'zidan oling. Logotip o'lchamini fayldan olamiz, matn kengligini stringWidth bilan o'lchaymiz. 600, 200, 28 kabi tasodifiy sonlarni kodga bog'lab qo'ymaslikka harakat qilamiz.

Dastur kerakli resurslarni o'zi boshqarsin. Kerakli papkalarni yaratsin, baza ulanishlarini vaqtida yopsin, loyiha esa ishlagan kutubxona versiyalarini aniq ko'rsatib tursin.

Test xatoni ushlay olishi kerak. Eski, xato kodda ham o'tib ketadigan test bizga uncha yordam bermaydi. Yaxshi test — muammo qaytsa, yiqiladigan test.

Nima qilib ko'rish mumkin#

  1. Bazaga uzun nomli mahsulot qo'shing, masalan Konditer mahsuloti — shokoladli pechene, katta quti. Nom chek eniga qanday moslashadi?

  2. pul() ichidagi ROUND_HALF_UP ni ROUND_DOWN ga o'zgartiring. Test o'tadimi? Chekdagi qatorlar bilan jami hali ham bir-biriga mos keladimi?

  3. Logotipni boshqa o'lchamdagi rasm bilan almashtiring. Chek qanday chiqadi? O'lchamlar kodga qo'lda yozilganida nima bo'lardi?

  4. Testdagi savatlar sonini 5000 ga oshiring. Test qancha vaqt oladi? Birorta zid holat chiqadimi?

Shu bilan chek ustidagi amaliyotimiz tugaydi. Endi qo'limizda faqat ishlaydigan dastur emas, uning to'g'ri ishlayotganini tekshiradigan test ham bor.

2 ko‘rish
Ulashish