الأدلة التقنية

اختبار ال API مع Bruno وPostman في خط CI

كيف تكتب اختبارات API تعيش في المستودع وتتحقق من العقد والصلاحيات، وتعمل في خط CI مع Bruno أو Postman.

اختبار ال API مع Bruno وPostman في خط CI

أغلب الفرق تبدأ اختبار ال API بأداة رسومية: يفتح المطور Postman، ويكتب الطلب، ويضغط Send، ويرى 200 فيطمئن. وبعد سنة يصبح لدى الفريق مئات الطلبات المحفوظة في حساب سحابي لمطور واحد، بعضها فيه توكن حقيقي، ولا يعمل أي منها في خط CI، ولا يعرف أحد هل ما زالت تمر. والمشكلة هنا ليست الأداة، وإنما أن الطلبات المحفوظة ليست اختبارات: لا تتحقق من شيء، ولا تعمل وحدها، ولا تعيش مع الكود في المستودع.

وقد شرحنا في أنواع الاختبارات للمبتدئين مكان اختبار ال API بين الطبقات وكتبنا مثالاً بأداة Hurl، وشرحنا تصميم ال API والعقد OpenAPI في كيف تصمم API لا تضطر إلى كسره، ومخاطره في أمن ال API مع OWASP API Security Top 10. أما هنا فسوف نبني مجموعة اختبارات حقيقية ل API صغير بأداة Bruno، ثم نكتب الاختبارات نفسها ل Postman ونشغلها بأداتي سطر الأوامر Newman وPostman CLI، ثم نكسر ال API ثلاث مرات ونرى ما يكشفه كل اختبار.

وسوف نناقش في هذا المقال ما يلي:

  • Bruno وPostman: الترخيص، وأين تحفظ المجموعة Collection، وحدود الخطة المجانية في Postman كما هي في أكتوبر 2026.
  • البيئات Environments والأسرار Secrets، ولماذا لا تكتب كلمة المرور في ملف البيئة.
  • التحققات Assertions والطلبات المتسلسلة Chained Requests، وتجهيز بيانات الاختبار وحذفها Setup وTeardown.
  • التحقق من الرد مقابل ملف OpenAPI المحفوظ في المستودع، ولماذا لا يكفي ملف OpenAPI الذي يولده السيرفر.
  • الحالات السلبية والأمنية: بدون توكن، ومستخدم يقرأ طلب غيره، ومستخدم عادي على مسار المدير، وقيمة خارج الحدود.
  • التشغيل في خط CI ب Bruno CLI وNewman وPostman CLI، وثلاثة أعطال متعمدة.

Bruno وPostman باختصار

Bruno أداة اختبار API مفتوحة المصدر بترخيص MIT بحسب مستودعها، وفكرتها الأساسية أن المجموعة مجلد ملفات نصية على جهازك تحفظه في Git مع الكود، ولا يوجد فيها حساب ولا مزامنة سحابية كما تقول صفحة الأسعار. ومنذ الإصدار 3.0.0 تستطيع حفظ الطلبات بصيغة YAML تسمى OpenCollection بدل صيغة .bru القديمة، وهي الصيغة الافتراضية للمجموعات الجديدة منذ 3.1.0. والنسخة المفتوحة مجانية، ومعها خطتان مدفوعتان Pro ب 6 دولارات وUltimate ب 11 دولاراً لكل مستخدم شهرياً بالدفع السنوي، وآخر إصدار من Bruno CLI على npm وقت كتابة المقال هو 4.2.1، وآخر Docker Image رسمية هي usebruno/cli:4.2.0 وهي التي استخدمناها.

أما Postman فمنصة تجارية مغلقة المصدر، والتطبيق نفسه يعمل دون حساب في وضع محدود، لكن المشاركة والمزامنة تحتاج حساباً. وفي مارس 2026 غيرت Postman خططها كما في إعلانها، وأصبحت الخطط Free وSolo وTeam وEnterprise. وما يهم الفريق في صفحة الأسعار كما قرأناها في 12 أكتوبر 2026:

الخطةالسعرعدد المستخدمينملاحظات
Free0مستخدم واحد50 رصيد AI شهرياً، ومراقبة API بحد 1000 طلب شهرياً، وحتى 5 تكاملات Integrations، واسترجاع المجموعة المحذوفة ليوم واحد
Solo9 دولارات شهرياً بالدفع السنويمستخدم واحدحدود أعلى للذكاء الاصطناعي والمراقبة
Team19 دولاراً لكل مستخدم شهرياً بالدفع السنويبلا حدالعمل المشترك بين الفريق والأدوار RBAC الأساسية
Enterpriseبالتواصل مع المبيعاتبلا حدالحوكمة وسجلات التدقيق Audit Logs

والنقطة العملية هنا أن الخطة المجانية لمستخدم واحد فقط، فإذا كان الفريق يتشارك المجموعات عبر سحابة Postman فهو يحتاج Team. وتشغيل المجموعات Collection Runner غير محدود في كل الخطط بحسب الصفحة نفسها، وهذه الأرقام تتغير، فراجع الصفحة قبل أن تقرر. ولم ننشئ حساباً في Postman لهذا المقال، فكل ما تراه تشغيل لملف محلي.

وهناك تغيير ثان يجب أن تعرفه قبل أن تبني خط CI على Postman: صفحة Newman في وثائق Postman تقول إن Newman لم يعد مطوراً، وإنه لا يقرأ صيغة المجموعات v3 التي يستخدمها Postman v12 مع Native Git، وتنصح بالانتقال إلى Postman CLI. وآخر إصدار من Newman هو 6.2.3 بتاريخ 9 أكتوبر 2026، وكل ما فيه بحسب سجل تغييراته سطر في ملف README يقدم Postman CLI. أما Postman CLI فهو أداة Postman الرسمية الحالية، وصفحته على npm لا تذكر ترخيصاً مفتوح المصدر، ويشغل المجموعة من ملف محلي دون حساب بحسب وثائق المصادقة، وسوف نتأكد من ذلك في التجربة، ولا يعمل على Alpine Linux بحسب صفحة الأسئلة.

ال API الذي سنختبره

ال API صغير بلغة Python وإطار FastAPI: تسجيل دخول يرجع توكن Bearer، وإنشاء طلب وقراءته وحذفه، ومسار للمدير يعرض كل الطلبات. وفيه مفتاحان لكسره عمداً: BROKEN=bola يسمح لأي مستخدم بقراءة طلب غيره، وBROKEN=rename يغير اسم الحقل total إلى amount، كأن مطوراً غيره دون أن يخبر أحداً. وشكل مجلد التجربة كما يلي:

lab-api/
├── api/                      # the Orders API (FastAPI)
│   ├── main.py
│   └── Dockerfile
├── bruno/                    # the Bruno collection, committed to Git
│   ├── opencollection.yml
│   ├── openapi.json          # the agreed contract
│   ├── .env.sample           # names of the secrets, no values
│   ├── .gitignore            # .env and reports/
│   ├── environments/local.yml
│   ├── lib/schema.js
│   ├── auth/                 # Login alice, Login bob, Wrong password
│   └── orders/               # Create order ... Cleanup delete order
└── postman/                  # the same tests as a Postman collection
"""Orders API for the API testing lab. Lab only: users, tokens and data live in memory.

BROKEN=bola    any logged-in user can read any order
BROKEN=rename  the field "total" is renamed to "amount" (a breaking change nobody announced)
"""
import os
import secrets

from fastapi import Depends, FastAPI, Header, HTTPException, Response
from pydantic import BaseModel, Field

BROKEN = os.environ.get("BROKEN", "")
app = FastAPI(title="Orders API", version="1.0.0")

USERS = {
    "alice": {"password": os.environ.get("ALICE_PASSWORD", "alice-lab-pass"), "role": "user"},
    "bob": {"password": os.environ.get("BOB_PASSWORD", "bob-lab-pass"), "role": "user"},
    "admin": {"password": os.environ.get("ADMIN_PASSWORD", "admin-lab-pass"), "role": "admin"},
}
PRODUCTS = {1: ("قهوة عربية", 45.0), 2: ("تمر سكري", 60.0), 3: ("هيل", 25.0)}
tokens: dict[str, str] = {}
orders: dict[int, dict] = {}
next_id = 1


class Login(BaseModel):
    username: str
    password: str


class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"


class OrderIn(BaseModel):
    product_id: int
    qty: int = Field(ge=1, le=10)


class Order(BaseModel):
    id: int
    owner: str
    product_id: int
    product: str
    qty: int
    total: float
    status: str


def current_user(authorization: str = Header(default="")) -> str:
    token = authorization.removeprefix("Bearer ").strip()
    if token not in tokens:
        raise HTTPException(401, "missing or invalid token", headers={"WWW-Authenticate": "Bearer"})
    return tokens[token]


def out(order: dict) -> dict:
    if BROKEN == "rename":
        order = {**order, "amount": order["total"]}
        del order["total"]
    return order


@app.post("/auth/token", response_model=Token)
def login(body: Login):
    user = USERS.get(body.username)
    if not user or not secrets.compare_digest(user["password"], body.password):
        raise HTTPException(401, "invalid credentials")
    token = secrets.token_urlsafe(24)
    tokens[token] = body.username
    return {"access_token": token}


# The schema is documented in OpenAPI but not enforced on the way out,
# like most frameworks: the spec and the code can drift apart
@app.post("/orders", status_code=201, response_model=None, responses={201: {"model": Order}})
def create_order(body: OrderIn, user: str = Depends(current_user)) -> dict:
    global next_id
    if body.product_id not in PRODUCTS:
        raise HTTPException(404, "product not found")
    name, price = PRODUCTS[body.product_id]
    order = {"id": next_id, "owner": user, "product_id": body.product_id, "product": name,
             "qty": body.qty, "total": round(price * body.qty * 1.15, 2), "status": "new"}
    orders[next_id] = order
    next_id += 1
    return out(order)


@app.get("/orders/{order_id}", response_model=None, responses={200: {"model": Order}})
def get_order(order_id: int, user: str = Depends(current_user)) -> dict:
    order = orders.get(order_id)
    # 404 and not 403: do not tell a stranger that the order exists
    if not order or (order["owner"] != user and BROKEN != "bola"):
        raise HTTPException(404, "order not found")
    return out(order)


@app.delete("/orders/{order_id}", status_code=204)
def delete_order(order_id: int, user: str = Depends(current_user)):
    order = orders.get(order_id)
    if not order or order["owner"] != user:
        raise HTTPException(404, "order not found")
    del orders[order_id]
    return Response(status_code=204)


@app.get("/admin/orders", response_model=list[Order])
def all_orders(user: str = Depends(current_user)):
    if USERS[user]["role"] != "admin":
        raise HTTPException(403, "admins only")
    return list(orders.values())


@app.get("/health")
def health():
    return {"status": "ok", "broken": BROKEN or None, "orders": len(orders)}

في الكود أعلاه لاحظ التالي:

  • شكل الطلب Order موصوف في ملف OpenAPI عبر responses، لكن FastAPI لا يتحقق من الرد الخارج في هذين المسارين كما تشرح وثائقه عن الردود الإضافية، وهذا هو حال أكثر الأطر، لذلك يمكن أن يختلف الكود عن الوصف.
  • الطلب الذي لا يملكه المستخدم يرجع 404 وليس 403، حتى لا يعرف الغريب أن الطلب موجود.
  • كلمات المرور من متغيرات البيئة، والقيم الافتراضية للتجربة فقط.
FROM python:3.13-slim
RUN pip install --no-cache-dir fastapi==0.142.2 uvicorn==0.54.0
WORKDIR /app
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
docker build -t lab-qa2-api:1 api
docker network create lab-qa2-net
docker run -d --name lab-qa2-api --network lab-qa2-net -p 127.0.0.1:18500:8000 lab-qa2-api:1
curl -s http://127.0.0.1:18500/health
{"status":"ok","broken":null,"orders":0}

مجموعة Bruno في Git

أسرع بداية أن تولد المجموعة من ملف OpenAPI بالأمر bru import، كما في وثائق الاستيراد:

docker run --rm --network lab-qa2-net -v "$PWD/gen:/bruno" usebruno/cli:4.2.0 import openapi \
  --source http://lab-qa2-api:8000/openapi.json --output /bruno --collection-name orders-api
find gen -type f
Reading OpenAPI specification from http://lab-qa2-api:8000/openapi.json...
Fetching specification from URL: http://lab-qa2-api:8000/openapi.json
Converting OpenAPI specification to Bruno format...
Bruno collection created at /bruno/orders-api
gen/orders-api/All Orders.yml
gen/orders-api/Create Order.yml
gen/orders-api/Delete Order.yml
gen/orders-api/Get Order.yml
gen/orders-api/Health.yml
gen/orders-api/Login.yml
gen/orders-api/opencollection.yml

الملفات المولدة طلب لكل مسار بقيم فارغة، وهي نقطة بداية جيدة، لكنها ليست اختبارات، فلا يوجد فيها توكن ولا تحقق ولا ترتيب. لذلك كتبنا المجموعة بأنفسنا في مجلدين، auth وorders، وملف المجموعة الرئيسي قصير:

opencollection: 1.0.0

info:
  name: orders-api
  version: "1"
bundled: false
extensions:
  bruno:
    ignore:
      - node_modules
      - .git

البيئات والأسرار

ملف البيئة يحدد عنوان ال API، أما كلمات المرور فيقرؤها من متغيرات النظام process.env، كما تشرح وثائق ملف .env:

name: local
variables:
  - name: baseUrl
    value: http://lab-qa2-api:8000
  - name: alicePassword
    value: "{{process.env.ALICE_PASSWORD}}"
  - name: bobPassword
    value: "{{process.env.BOB_PASSWORD}}"

وعلى جهاز المطور تأتي القيم من ملف .env في مجلد المجموعة، وهو في .gitignore، ومعه ملف .env.sample فيه الأسماء دون القيم ليعرف الزميل ما يحتاجه:

ALICE_PASSWORD=alice-lab-pass
BOB_PASSWORD=bob-lab-pass
.env
reports/

وقد يتساءل البعض: لماذا لا نكتب كلمة المرور في ملف البيئة مباشرة، وهو على جهازي؟ والإجابة أن ملف البيئة جزء من المجموعة ويرفع إلى المستودع مع كل commit، وأي قيمة فيه تصبح في تاريخ Git إلى الأبد، وقد شرحنا فحص الأسرار في المستودعات في اكتشاف الأسرار المسربة. وفي Bruno طريقة ثانية هي المتغيرات السرية Secret Variables التي يحفظها التطبيق مشفرة على الجهاز ولا يكتبها في الملف، كما في وثائقها، لكنها تعيش في التطبيق الرسومي، أما خط CI فيحتاج متغيرات البيئة، لذلك اخترنا process.env للطرفين.

الطلبات المتسلسلة وبيانات الاختبار

ال API يحتاج توكن، والتوكن يأتي من طلب الدخول، ورقم الطلب يأتي من طلب الإنشاء، فالطلبات مرتبطة ببعضها، والصورة التالية تبين الترتيب:

مخطط لتسلسل الطلبات: كلمات المرور من ملف .env على جهاز المطور أو من أسرار خط CI، ثم Login alice وLogin bob يحفظان aliceToken وbobToken، ثم Create order يحفظ orderId، ثم الطلبات السلبية تستخدم التوكنات ورقم الطلب، ثم Cleanup delete order يحذف الطلب في النهاية
كل طلب يحفظ ما يحتاجه الطلب الذي بعده، والطلب الأخير يحذف بيانات الاختبار

طلب الدخول يتحقق من الرد ثم يحفظ التوكن في متغير وقت التشغيل Runtime Variable بالدالة bru.setVar:

info:
  name: Login alice
  type: http
  seq: 1

http:
  method: POST
  url: "{{baseUrl}}/auth/token"
  body:
    type: json
    data: |-
      {
        "username": "alice",
        "password": "{{alicePassword}}"
      }

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "200"
    - expression: res.body.access_token
      operator: isString
  scripts:
    - type: after-response
      code: |-
        // the next requests use this token
        bru.setVar("aliceToken", res.body.access_token);

settings:
  encodeUrl: true

وطلب الإنشاء يستخدم التوكن، ويتحقق من رمز الحالة والمالك والإجمالي، ثم يحفظ رقم الطلب، وهذا الطلب هو تجهيز بيانات الاختبار Setup:

info:
  name: Create order
  type: http
  seq: 1

http:
  method: POST
  url: "{{baseUrl}}/orders"
  body:
    type: json
    data: |-
      {
        "product_id": 1,
        "qty": 2
      }
  auth:
    type: bearer
    token: "{{aliceToken}}"

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "201"
    - expression: res.body.owner
      operator: eq
      value: alice
  scripts:
    - type: after-response
      code: |-
        // test data: the cleanup request at the end of the folder deletes it
        bru.setVar("orderId", res.body.id);
    - type: tests
      code: |-
        const { schemaErrors } = require('./lib/schema');

        test("total is 2 x 45 SAR plus 15% VAT", function () {
          expect(res.body.total).to.equal(103.5);
        });

        test("matches the Order schema in openapi.json", function () {
          const errors = schemaErrors("Order", res.body);
          expect(errors, errors.join("; ")).to.be.empty;
        });

settings:
  encodeUrl: true

والطلب الأخير في المجلد يحذف الطلب Teardown، ورقمه 99 حتى يبقى آخر طلب مهما أضفنا قبله. ولاحظ أن Bruno يكمل الطلبات بعد فشل أحدها إلا إذا استخدمت --bail، لذلك يعمل الحذف حتى عندما يفشل اختبار قبله:

info:
  name: Cleanup delete order
  type: http
  seq: 99

http:
  method: DELETE
  url: "{{baseUrl}}/orders/{{orderId}}"
  auth:
    type: bearer
    token: "{{aliceToken}}"

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "204"

settings:
  encodeUrl: true

التحقق مقابل ملف OpenAPI

التحقق من رمز الحالة لا يكفي، فال API قد يرجع 201 وشكل الرد مختلف عما يتوقعه التطبيق الذي يستخدمه. لذلك نتحقق من الرد مقابل مخطط Order في ملف OpenAPI. لكن أي ملف؟ الطريقة البسيطة أن تقرأ /openapi.json من السيرفر نفسه أثناء الاختبار، وسوف ترى بعد قليل لماذا لا تكفي، لذلك حفظنا الملف في المستودع كعقد متفق عليه، وأي تغيير فيه يمر بطلب دمج يراجعه الفريق:

curl -s http://127.0.0.1:18500/openapi.json > bruno/openapi.json

والدالة التالية تقرأ الملف وتتحقق من الرد بمكتبة Ajv، وهي من المكتبات المضمنة في Bruno بحسب وثائق المكتبات:

// Validate a response body against a schema from the OpenAPI file kept in the repository
// (the agreed contract), not against what the running server says about itself.
const fs = require('fs');
const path = require('path');
const Ajv = require('ajv');

const spec = JSON.parse(fs.readFileSync(path.join(bru.cwd(), 'openapi.json'), 'utf8'));
const ajv = new Ajv({ strict: false, allErrors: true });

function schemaErrors(name, body) {
  const validate = ajv.compile({ $ref: `#/components/schemas/${name}`, components: spec.components });
  return validate(body) ? [] : validate.errors.map((e) => `${e.instancePath || '/'} ${e.message}`);
}

module.exports = { schemaErrors };

والتحقق نفسه في طلب قراءة الطلب، ومعه تحققات تصريحية Declarative لا تحتاج كوداً:

info:
  name: Get own order
  type: http
  seq: 2

http:
  method: GET
  url: "{{baseUrl}}/orders/{{orderId}}"
  auth:
    type: bearer
    token: "{{aliceToken}}"

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "200"
    - expression: res.body.id
      operator: eq
      value: "{{orderId}}"
  scripts:
    - type: tests
      code: |-
        const { schemaErrors } = require('./lib/schema');

        test("matches the Order schema in openapi.json", function () {
          const errors = schemaErrors("Order", res.body);
          expect(errors, errors.join("; ")).to.be.empty;
        });

settings:
  encodeUrl: true

الحالات السلبية والأمنية

أخطر أخطاء ال API لا تظهر في الطريق السعيد Happy Path، فالطلب الذي يرجع بيانات غيرك يرجع 200 مثل الطلب الصحيح تماماً. لذلك لكل مسار مهم اختبار يتوقع الرفض، وعليها الوسم security. الأول بوب يقرأ طلب أليس، وهو خطر BOLA الأول في قائمة OWASP:

info:
  name: Bob cannot read it
  type: http
  seq: 3
  tags:
    - security

http:
  method: GET
  url: "{{baseUrl}}/orders/{{orderId}}"
  auth:
    type: bearer
    token: "{{bobToken}}"

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "404"
    - expression: res.body.owner
      operator: isUndefined

settings:
  encodeUrl: true

والثاني بدون توكن، ويتحقق من الترويسة WWW-Authenticate أيضاً، والثالث مستخدم عادي على مسار المدير، والرابع كمية خارج الحدود:

info:
  name: No token
  type: http
  seq: 4
  tags:
    - security

http:
  method: GET
  url: "{{baseUrl}}/orders/{{orderId}}"

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "401"
    - expression: res.headers['www-authenticate']
      operator: eq
      value: Bearer

settings:
  encodeUrl: true
info:
  name: User cannot list all orders
  type: http
  seq: 5
  tags:
    - security

http:
  method: GET
  url: "{{baseUrl}}/admin/orders"
  auth:
    type: bearer
    token: "{{aliceToken}}"

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "403"

settings:
  encodeUrl: true
info:
  name: Quantity out of range
  type: http
  seq: 6

http:
  method: POST
  url: "{{baseUrl}}/orders"
  body:
    type: json
    data: |-
      {
        "product_id": 1,
        "qty": 11
      }
  auth:
    type: bearer
    token: "{{aliceToken}}"

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "422"
    - expression: res.body.detail[0].loc[1]
      operator: eq
      value: qty

settings:
  encodeUrl: true

ولاحظ أن طلب «No token» لا يحدد auth، فيرث من المجموعة التي لا توكن فيها. وفي محاولتنا الأولى كتبنا auth: none في هذه الطلبات، فطبع Bruno CLI 4.2.0 السطر toBrunoAuth failed: Unsupported auth type مرة لكل طلب، ثم أرسلها دون توكن، فحذفنا السطر.

التشغيل: الوضع الآمن ووضع المطور

نشغل Bruno CLI من ال Docker Image الرسمية كما في وثائق Docker، وال Image تعمل بالمستخدم node:

#!/bin/sh
# usage: ./bru.sh run ...   (Bruno CLI 4.2.0 on the lab network)
docker run --rm --name lab-qa2-bru --network lab-qa2-net \
  -v "$PWD/bruno:/bruno" usebruno/cli:4.2.0 "$@"
./bru.sh run --env local -r
orders/Create order (201 Created) - 6 ms
Tests
   ✕ Test Script Error
      Cannot find module fs
Assertions
   ✓ res.status: eq 201
   ✓ res.body.owner: eq alice
...
│ Requests      │ 10 (7 Passed, 3 Failed) │

منذ الإصدار 3.0.0 يعمل CLI بالوضع الآمن Safe Mode افتراضياً، وفيه تعمل السكربتات في بيئة معزولة لا ترى الملفات ولا أوامر النظام، كما تشرح وثائق ال Sandbox، ودالتنا تقرأ openapi.json من القرص. والحل الخيار --sandbox=developer، وهو آمن هنا لأن المجموعة في مستودعنا، أما مجموعة نزلتها من الإنترنت فلا تقم بتشغيلها بهذا الخيار، والسبب أن أي سكربت فيها يستطيع قراءة ملفات جهازك ومتغيراته:

mkdir -p bruno/reports
./bru.sh run --env local -r --sandbox=developer --reporter-junit reports/results.xml
curl -s http://127.0.0.1:18500/health
📊 Execution Summary
┌───────────────┬────────────────┐
│ Metric        │     Result     │
├───────────────┼────────────────┤
│ Status        │     ✓ PASS     │
├───────────────┼────────────────┤
│ Requests      │ 10 (10 Passed) │
├───────────────┼────────────────┤
│ Tests         │      3/3       │
├───────────────┼────────────────┤
│ Assertions    │     17/17      │
├───────────────┼────────────────┤
│ Duration (ms) │      968       │
└───────────────┴────────────────┘
Wrote junit results to reports/results.xml

{"status":"ok","broken":null,"orders":0}

في المخرج أعلاه لاحظ التالي:

  • عشرة طلبات، وثلاثة اختبارات بالكود، وسبعة عشر تحققاً تصريحياً، كلها نجحت في أقل من ثانية.
  • /health يعرض orders: 0 بعد التشغيل، أي أن الحذف في النهاية نظف ما أنشأه الاختبار.
  • تقرير JUnit يكتب في مجلد يجب أن يكون موجوداً، ففي محاولتنا الأولى طبع Bruno الرسالة Output directory reports does not exist ولم يكتب شيئاً.

وفي ملف JUnit يأخذ كل تحقق سطراً، وclassname هو مسار الطلب في المجموعة، وهذا تغيير في الإصدار 4 بحسب وثائق التقارير، فإذا كنت تربط النتائج بأداة إدارة اختبارات مثل Kiwi TCMS فالاسم ثابت بين البيئات:

<testsuite name="Login alice" file="auth/Login alice.yml" errors="0" failures="0" skipped="0" tests="2" timestamp="2026-10-12T17:16:36.514" hostname="3d7477869f56" time="0.320">
  <testcase name="res.status eq 200" status="pass" classname="auth/Login alice" time="0.160"/>
  <testcase name="res.body.access_token isString" status="pass" classname="auth/Login alice" time="0.160"/>
</testsuite>

الاختبارات نفسها في Postman

كتبنا المجموعة نفسها تقريباً بصيغة Postman v2.1، وهي الصيغة التي يقرؤها Newman، والتحقق من المخطط بالتحقق jsonSchema المضمن في Postman، والمخطط من /openapi.json الذي يجلبه سكربت المجموعة مرة واحدة:

{
  "info": {
    "name": "orders-api",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "exec": [
          "// load the OpenAPI document once per run, for the schema checks",
          "if (!pm.collectionVariables.get('spec')) {",
          "  pm.sendRequest(pm.environment.get('baseUrl') + '/openapi.json', (err, res) => {",
          "    pm.collectionVariables.set('spec', res.text());",
          "  });",
          "}"
        ]
      }
    }
  ],
  "item": [
    {
      "name": "Login alice",
      "request": {
        "method": "POST",
        "url": "{{baseUrl}}/auth/token",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": { "mode": "raw", "raw": "{\"username\": \"alice\", \"password\": \"{{alicePassword}}\"}" }
      },
      "event": [{ "listen": "test", "script": { "exec": [
        "pm.test('status is 200', () => pm.response.to.have.status(200));",
        "pm.collectionVariables.set('aliceToken', pm.response.json().access_token);"
      ] } }]
    },
    {
      "name": "Login bob",
      "request": {
        "method": "POST",
        "url": "{{baseUrl}}/auth/token",
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": { "mode": "raw", "raw": "{\"username\": \"bob\", \"password\": \"{{bobPassword}}\"}" }
      },
      "event": [{ "listen": "test", "script": { "exec": [
        "pm.test('status is 200', () => pm.response.to.have.status(200));",
        "pm.collectionVariables.set('bobToken', pm.response.json().access_token);"
      ] } }]
    },
    {
      "name": "Create order",
      "request": {
        "method": "POST",
        "url": "{{baseUrl}}/orders",
        "auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "{{aliceToken}}", "type": "string" }] },
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": { "mode": "raw", "raw": "{\"product_id\": 1, \"qty\": 2}" }
      },
      "event": [{ "listen": "test", "script": { "exec": [
        "pm.test('status is 201', () => pm.response.to.have.status(201));",
        "pm.test('total is 103.5', () => pm.expect(pm.response.json().total).to.eql(103.5));",
        "pm.test('matches the Order schema', () => {",
        "  const spec = JSON.parse(pm.collectionVariables.get('spec'));",
        "  pm.response.to.have.jsonSchema({ $ref: '#/components/schemas/Order', components: spec.components });",
        "});",
        "pm.collectionVariables.set('orderId', pm.response.json().id);"
      ] } }]
    },
    {
      "name": "Bob cannot read it",
      "request": {
        "method": "GET",
        "url": "{{baseUrl}}/orders/{{orderId}}",
        "auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "{{bobToken}}", "type": "string" }] }
      },
      "event": [{ "listen": "test", "script": { "exec": [
        "pm.test('status is 404', () => pm.response.to.have.status(404));"
      ] } }]
    },
    {
      "name": "Quantity out of range",
      "request": {
        "method": "POST",
        "url": "{{baseUrl}}/orders",
        "auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "{{aliceToken}}", "type": "string" }] },
        "header": [{ "key": "Content-Type", "value": "application/json" }],
        "body": { "mode": "raw", "raw": "{\"product_id\": 1, \"qty\": 11}" }
      },
      "event": [{ "listen": "test", "script": { "exec": [
        "pm.test('status is 422', () => pm.response.to.have.status(422));"
      ] } }]
    },
    {
      "name": "Cleanup delete order",
      "request": {
        "method": "DELETE",
        "url": "{{baseUrl}}/orders/{{orderId}}",
        "auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "{{aliceToken}}", "type": "string" }] }
      },
      "event": [{ "listen": "test", "script": { "exec": [
        "pm.test('status is 204', () => pm.response.to.have.status(204));"
      ] } }]
    }
  ]
}
{
  "name": "local",
  "values": [
    { "key": "baseUrl", "value": "http://lab-qa2-api:8000", "enabled": true },
    { "key": "alicePassword", "value": "", "type": "secret", "enabled": true },
    { "key": "bobPassword", "value": "", "type": "secret", "enabled": true }
  ]
}

ولاحظ الفرق في كلمات المرور: في ملف البيئة نوعها secret وقيمتها فارغة، والقيمة تأتي من سطر الأوامر --env-var. ثم Image فيها الأداتان بإصدارات محددة، على Debian وليس Alpine لأن Postman CLI لا يدعمه:

FROM node:22-bookworm-slim
RUN npm install -g [email protected] [email protected] && npm cache clean --force
WORKDIR /work
docker build -t lab-qa2-newman:1 postman
docker run --rm --network lab-qa2-net -e ALICE_PASSWORD -e BOB_PASSWORD \
  -v "$PWD/postman:/work" lab-qa2-newman:1 sh -c '
  newman run orders-api.postman_collection.json -e local.postman_environment.json \
    --env-var alicePassword=$ALICE_PASSWORD --env-var bobPassword=$BOB_PASSWORD \
    -r cli,junit --reporter-junit-export reports/newman.xml'
→ Create order
  POST http://lab-qa2-api:8000/orders [201 Created, 239B, 13ms]
  ✓  status is 201
  ✓  total is 103.5
  ✓  matches the Order schema

→ Bob cannot read it
  GET http://lab-qa2-api:8000/orders/1 [404 Not Found, 160B, 10ms]
  ✓  status is 404
...
│              assertions │                 8 │                0 │
├─────────────────────────┴───────────────────┴──────────────────┤
│ total run duration: 737ms                                      │

ثم الملف نفسه بأداة Postman CLI دون تسجيل دخول:

docker run --rm --network lab-qa2-net -e ALICE_PASSWORD -e BOB_PASSWORD \
  -v "$PWD/postman:/work" lab-qa2-newman:1 sh -c '
  postman collection run orders-api.postman_collection.json -e local.postman_environment.json \
    --env-var alicePassword=$ALICE_PASSWORD --env-var bobPassword=$BOB_PASSWORD'
echo "exit=$?"
No authorization data found. Please use the `postman login` command.
Refer:https://learning.postman.com/docs/postman-cli/postman-cli-options/#postman-login

Running your collection...
...
Root Bob cannot read it

  GET http://lab-qa2-api:8000/orders/2 [404 Not Found, 160 B, 5 ms]
  Pass  status is 404
...
|              assertions |                 8 |                 0 |
...
Postman credentials are required for publishing run details to Postman cloud.
Please specify it by using the `postman login` command
exit=0

Postman CLI طبع تنبيهاً بأنه لا توجد بيانات دخول، ثم شغل المجموعة كاملة من الملف المحلي ونجحت التحققات الثمانية، وخرج بالرمز 0، أما رفع النتائج إلى سحابة Postman فيحتاج postman login كما تقول الرسالة الأخيرة.

ثلاثة أعطال: ما الذي يكشفه كل اختبار؟

العطل الأول: اسم حقل تغير دون إعلان

docker rm -f lab-qa2-api
docker run -d --name lab-qa2-api --network lab-qa2-net -p 127.0.0.1:18500:8000 -e BROKEN=rename lab-qa2-api:1
./bru.sh run --env local -r --sandbox=developer
orders/Create order (201 Created) - 30 ms
Tests
   ✕ total is 2 x 45 SAR plus 15% VAT
AssertionError: expected undefined to equal 103.5
   ✕ matches the Order schema in openapi.json
AssertionError: / must have required property 'total': expected [ Array(1) ] to be empty
Assertions
   ✓ res.status: eq 201
   ✓ res.body.owner: eq alice
orders/Get own order (200 OK) - 140 ms
Tests
   ✕ matches the Order schema in openapi.json
AssertionError: / must have required property 'total': expected [ Array(1) ] to be empty
Assertions
   ✓ res.status: eq 200
   ✓ res.body.id: eq {{orderId}}
...
│ Requests      │ 10 (8 Passed, 2 Failed) │
│ Tests         │           0/3           │
│ Assertions    │          17/17          │

في المخرج أعلاه لاحظ التالي:

  • كل التحققات التصريحية نجحت، 17 من 17، فرموز الحالة والمالك صحيحة، ولو كانت هذه كل اختباراتنا لمر التغيير إلى الإنتاج Production وكسر كل تطبيق يقرأ total.
  • التحقق من المخطط أعطى سبباً واضحاً: الحقل المطلوب total غير موجود، وفي كل طلب يرجع Order وليس في طلب واحد.

والآن لماذا حفظنا ملف OpenAPI في المستودع بدل أن نقرأه من السيرفر؟ قارنا الملف الذي يقدمه السيرفر المكسور بالملف المحفوظ:

curl -s http://127.0.0.1:18500/openapi.json | node -e '
let d = ""; process.stdin.on("data", c => d += c).on("end", () => {
  const live = JSON.stringify(JSON.parse(d));
  const committed = JSON.stringify(require("./bruno/openapi.json"));
  console.log("live spec identical to committed:", live === committed);
})'
live spec identical to committed: true

الملف الذي يولده FastAPI لم يتغير، لأنه يولد من النموذج Order في الكود وليس من الرد الحقيقي، فالرد فيه amount والوصف ما زال يقول total. وفي هذه الحالة كان التحقق مقابل الملف الحي سيكشف الخطأ أيضاً، وهذا ما حدث في Postman الذي يقرأ /openapi.json من السيرفر:

 1.  AssertionError  total is 103.5
                     expected undefined to deeply equal 103.5
                     at assertion:1 in test-script
                     inside "Create order"

 2.  AssertionError  matches the Order schema
                     expected data to satisfy schema but found following errors:
                     data should have required property 'total'
                     at assertion:2 in test-script
                     inside "Create order"

لكن لنفرض أن المطور غير اسم الحقل في النموذج Order نفسه، وهو التغيير الأكثر شيوعاً، فسوف يتغير الرد والوصف الحي معاً، ويمر التحقق مقابل الملف الحي، بينما تنكسر كل التطبيقات التي تقرأ total. أما الملف المحفوظ في المستودع فلا يتغير إلا بطلب دمج يراه الفريق، فيكشف الحالتين. لذلك اجعل العقد ملفاً في المستودع، وأضف إلى خط CI خطوة تقارن الملف الحي بالمحفوظ وتفشل عند أي اختلاف، فيصبح كل تغيير في العقد قراراً مكتوباً وليس أثراً جانبياً.

والصورة التالية تبين الفرق بين المرجعين في الحالتين:

مخطط بحالتين: في الأولى تغير الكود وحده فأصبح الرد فيه amount، والملف المحفوظ في المستودع والملف الحي يقولان total فيكشف الاختباران الخطأ، وفي الثانية تغير النموذج في الكود فأصبح الملف الحي يقول amount أيضاً فينجح الاختبار مقابل الملف الحي، ويفشل الاختبار مقابل الملف المحفوظ في المستودع
العقد المحفوظ في المستودع يكشف التغيير في الحالتين، والملف الحي يتغير مع الكود

العطل الثاني: بوب يقرأ طلب أليس

شغلنا ال API مع BROKEN=bola، وهو خطأ من نوع تفويض مستوى الكائن المكسور Broken Object Level Authorization:

orders/Bob cannot read it (200 OK) - 6 ms
Assertions
   ✕ res.status: eq 404
      expected 200 to equal 404
   ✕ res.body.owner: isUndefined
      expected 'alice' to be undefined

كل الطلبات الأخرى نجحت، ورد ال API ب 200 وفيه owner: alice، فالاختبار السلبي وحده كشف أن أي مستخدم يقرأ طلبات غيره. وهذا هو السبب في أن الاختبار يتحقق من رمز الحالة ومن غياب البيانات معاً.

وقد تريد تشغيل الاختبارات الأمنية وحدها بالوسم security:

./bru.sh run --env local -r --sandbox=developer --tags=security
orders/Bob cannot read it (401 Unauthorized) - 49 ms
Assertions
   ✕ res.status: eq 404
      expected 401 to equal 404
   ✓ res.body.owner: isUndefined
orders/No token (401 Unauthorized) - 11 ms
Assertions
   ✓ res.status: eq 401
   ✓ res.headers['www-authenticate']: eq Bearer
orders/User cannot list all orders (401 Unauthorized) - 6 ms
Assertions
   ✕ res.status: eq 403
      expected 401 to equal 403

النتيجة 401 بدل 404 و403، والسبب أن الوسم اختار ثلاثة طلبات فقط، ولم يشغل طلبات الدخول التي تحفظ التوكن، فأرسلت الطلبات دون توكن صالح. وهذا شكل من أخطاء الطلبات المتسلسلة: الطلب لا يعمل وحده. ولاحظ أن التحقق من رمز الحالة بالضبط هو ما كشف المشكلة، فلو كان التحقق «ليس 200» لنجحت الاختبارات الثلاثة وهي لا تختبر شيئاً. والحل أن تعطي طلبات الدخول الوسم نفسه، أو تشغل المجلدين بالترتيب.

العطل الثالث: خط CI دون أسرار

نقلنا ملف .env خارج المجموعة، كأن الاختبار يعمل في خط CI لم تضف إليه الأسرار:

mv bruno/.env /tmp/env.bak
./bru.sh run auth --env local -r --sandbox=developer
auth/Login alice (401 Unauthorized) - 96 ms
Assertions
   ✕ res.status: eq 200
      expected 401 to equal 200
   ✕ res.body.access_token: isString
      expected undefined to be a string
auth/Login bob (401 Unauthorized) - 17 ms
Assertions
   ✕ res.status: eq 200
      expected 401 to equal 200
auth/Wrong password (401 Unauthorized) - 10 ms
Assertions
   ✓ res.status: eq 401
   ✓ res.body.access_token: isUndefined

الدخول فشل ب 401، وكل ما بعده سيفشل بسببه. والحل في خط CI أن تأتي القيم من متغيرات البيئة، وBruno يقرأ process.env من متغيرات ال Container أيضاً، فلا تحتاج ملف .env هناك:

docker run --rm --network lab-qa2-net -e ALICE_PASSWORD -e BOB_PASSWORD \
  -v "$PWD/bruno:/bruno" usebruno/cli:4.2.0 run auth --env local -r --sandbox=developer
auth/Login alice (200 OK) - 73 ms
auth/Login bob (200 OK) - 15 ms
auth/Wrong password (401 Unauthorized) - 7 ms
│ Status        │    ✓ PASS    │

ملف خط CI

الملف التالي ل GitHub Actions ويعمل بالشكل نفسه على Gitea Actions، وهو يشغل ال API في Container، ثم Bruno CLI على شبكته، ويحفظ تقرير JUnit كملف مرفق Artifact، والخيار --reporter-skip-all-headers يمنع ترويسة Authorization من الوصول إلى التقرير كما في خيارات bru run. والبيئة ci فيها عنوان ال API داخل شبكة خط CI:

name: ci
variables:
  - name: baseUrl
    value: http://orders-api:8000
  - name: alicePassword
    value: "{{process.env.ALICE_PASSWORD}}"
  - name: bobPassword
    value: "{{process.env.BOB_PASSWORD}}"
name: api-tests
on: [push, pull_request]

jobs:
  api:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Start the API
        run: |
          docker build -t orders-api:ci api
          docker network create api-net
          docker run -d --name orders-api --network api-net orders-api:ci
          sleep 3
      - name: Bruno
        env:
          ALICE_PASSWORD: ${{ secrets.ALICE_PASSWORD }}
          BOB_PASSWORD: ${{ secrets.BOB_PASSWORD }}
        run: |
          mkdir -p bruno/reports && chmod 777 bruno/reports
          docker run --rm --network api-net -e ALICE_PASSWORD -e BOB_PASSWORD \
            -v "$PWD/bruno:/bruno" usebruno/cli:4.2.0 \
            run --env ci -r --sandbox=developer \
            --reporter-junit reports/results.xml --reporter-skip-all-headers
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: api-results
          path: bruno/reports
📌
شغلنا كل أوامر هذا الملف على جهازنا داخل Containers كما في الأقسام السابقة، أما الملف نفسه فلم نشغله على GitHub أو Gitea في هذه التجربة، وقد شغلنا خط CI كاملاً على Gitea Actions مع runner خاص في مقال اختبارات E2E مع Playwright.

Bruno أم Postman؟

شغلنا الأداتين على المجموعة نفسها والأعطال نفسها، والفرق الحقيقي ليس في القدرة على الاختبار، فكلاهما كشف كل الأعطال، وإنما في أين تعيش المجموعة ومن يملكها.

Bruno

  • نقاط القوة: مفتوح المصدر بترخيص MIT، والمجموعة ملفات YAML في المستودع تراجع في طلب الدمج مثل الكود، ولا حساب ولا سحابة، وCLI رسمي في Docker Image، والوضع الآمن افتراضي للسكربتات.
  • نقاط الضعف: المكتبات الخارجية وقراءة الملفات تحتاج وضع المطور، وبعض المزايا مثل التكامل الأعمق مع Git في الخطط المدفوعة، والصيغة YAML جديدة نسبياً فقد تتغير تفاصيلها، كما رأينا مع auth: none.

Postman

  • نقاط القوة: الأداة الأشهر وكثير من المطورين يعرفونها، ومعها المراقبة Monitors والمحاكاة Mocks والتوثيق، وPostman CLI يشغل ملفاً محلياً دون حساب.
  • نقاط الضعف: منصة مغلقة المصدر، والعمل المشترك عبر السحابة يحتاج خطة Team، وNewman لم يعد مطوراً ولا يقرأ صيغة v3، والانتقال إلى Native Git يعني صيغة جديدة.
BrunoPostman
الترخيصMIT، وخطط مدفوعة اختياريةتجاري مغلق المصدر، وNewman بترخيص Apache-2.0
مكان المجموعةملفات في المستودعسحابة Postman، أو ملفات مع Native Git في v12
الحسابلا يوجدمطلوب للمشاركة والمزامنة
الفريقعبر Git مجاناًخطة Team ب 19 دولاراً للمستخدم شهرياً
سطر الأوامرbru، ومعه Docker Image رسميةPostman CLI، وNewman للصيغة القديمة
التقاريرJSON وJUnit وHTMLCLI وJSON وJUnit وHTML
التحقق من المخططAjv في وضع المطورjsonSchema مضمن

والقرار في رأينا بسيط: إذا كان فريقك يريد أن تعيش الاختبارات مع الكود وتراجع في طلبات الدمج دون حسابات سحابية، فالأنسب Bruno. وإذا كان الفريق يعتمد على Postman ومزاياه السحابية ومستعداً لخطة Team، فاستمر عليه وانقل خط CI إلى Postman CLI، ولا تبدأ مشروعاً جديداً على Newman.

إزالة التجربة

docker rm -f lab-qa2-api
docker network rm lab-qa2-net
docker rmi lab-qa2-api:1 lab-qa2-newman:1 usebruno/cli:4.2.0 node:22-bookworm-slim

أخطاء شائعة

  • طلبات محفوظة بلا تحققات: الطلب الذي يرجع 200 دون تحقق من الجسم ليس اختباراً.
  • رمز الحالة وحده: اسم حقل تغير يمر من كل تحققات رمز الحالة، كما رأيت في العطل الأول.
  • العقد من السيرفر نفسه: الملف المولد يتغير مع الكود، فاحفظ العقد في المستودع.
  • الطريق السعيد فقط: لكل مسار مهم اختبار لمستخدم آخر، ولطلب بلا توكن، ولقيمة خارج الحدود.
  • تحقق «ليس 200»: تحقق من الرمز المتوقع بالضبط، وإلا نجح الاختبار لسبب خطأ.
  • أسرار في ملف البيئة: ملف البيئة يرفع مع المجموعة، والأسرار من .env محلياً ومن أسرار خط CI.
  • بيانات لا تحذف: كل ما ينشئه الاختبار يحذفه طلب في النهاية، وإلا تراكمت البيانات وتغيرت نتائج التشغيلات التالية.
  • وضع المطور مع مجموعة غريبة: سكربتاتها تستطيع قراءة ملفات جهازك.
  • الترويسات في التقارير: استخدم --reporter-skip-all-headers أو --reporter-skip-headers قبل رفع التقرير كملف مرفق.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • مجموعة الاختبارات ملفات في المستودع، تعمل من سطر الأوامر في خط CI، وتجهز بياناتها وتحذفها بنفسها.
  • التحقق من المخطط مقابل عقد محفوظ في المستودع كشف تغيير اسم الحقل، بينما نجحت كل تحققات رمز الحالة.
  • الاختبارات السلبية هي التي تكشف أخطاء الصلاحيات، وتحقق من الرمز المتوقع بالضبط.
  • الأسرار من .env على جهاز المطور ومن متغيرات البيئة في خط CI، وليس من ملف البيئة.
  • Bruno مفتوح المصدر ومجموعته في Git، وPostman منصة تجارية خطتها المجانية لمستخدم واحد، وNewman لم يعد مطوراً.

وفي مقالات قادمة بإذن الله سوف نتحدث عن اختبار العقود بين الخدمات بالتفصيل وعن فحص ال API أثناء التشغيل.

دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.دليلاختبار العقود Contract Testing بين الخدمات مع Pactكيف تمنع خدمة من كسر خدمة أخرى تعتمد عليها قبل النشر، بعقد يكتبه المستهلك ويتحقق منه المزود ويحفظه Pact Broker.دليلأمن ال API بالأمثلة مع OWASP API Security Top 10أشهر عشرة مخاطر في واجهات API كما ترتبها OWASP، ولكل خطر مثال لكود ضعيف وكيف تصلحه وتختبره.دليلكيف تصمم API لا تضطر إلى كسره بعد سنةمبادئ تصميم API يتطور دون أن يكسر التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.دليلاختبارات E2E مع Playwright: من أول اختبار إلى CIكيف تكتب اختبارات متصفح ثابتة لموقع عربي مع Playwright، وتشخص الاختبار المتقلب، وتقسمها على أكثر من جهاز في خط CI.دليلKiwi TCMS: إدارة حالات الاختبار والأخطاء على سيرفرككيف تنظم حالات الاختبار ونتائجها اليدوية والآلية وأخطاءها في Kiwi TCMS على سيرفرك، وتربطها بمشكلات Gitea.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ تجربة على API بلغة Python 3.13 مع FastAPI 0.142.2 وuvicorn 0.54.0، واختبارات Bruno CLI 4.2.0 (usebruno/cli:4.2.0) وNewman 6.2.3 وPostman CLI 1.73.0 على node:22-bookworm-slim، والتحقق من أسعار Postman وBruno من صفحاتهما في 12 أكتوبر 2026.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.